Organize backend into product executable and shared Go modules
This commit is contained in:
1 parent
981e84a4b4
commit
f0720ec66b
382 files changed
+1944
-6989
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/shared/errors"
|
||||
)
|
||||
|
||||
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,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,25 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
ClientID string `bson:"clientId"`
|
||||
ClientName string `bson:"clientName"`
|
||||
ClientSecret string `bson:"clientSecret,omitempty"`
|
||||
AllowedCIDRs []string `bson:"allowedCIDRs,omitempty"`
|
||||
AllowedScopes []string `bson:"allowedScopes"`
|
||||
RedirectURIs []string `bson:"redirectURIs"`
|
||||
GrantTypes []string `bson:"grantTypes"`
|
||||
TokenEndpointAuthMethod string `bson:"tokenEndpointAuthMethod"`
|
||||
AccountRef *bson.ObjectID `bson:"accountRef,omitempty"` // owner reference
|
||||
IsRevoked bool `bson:"isRevoked"`
|
||||
}
|
||||
|
||||
func (*Client) Collection() string {
|
||||
return mservice.Clients
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type ClientRefreshToken struct {
|
||||
SessionIdentifier `bson:",inline" json:",inline"`
|
||||
RefreshToken string `bson:"token" json:"token"`
|
||||
}
|
||||
|
||||
type RefreshToken struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
ClientRefreshToken `bson:",inline" json:",inline"`
|
||||
AccountRef *bson.ObjectID `bson:"accountRef,omitempty" json:"accountRef,omitempty"`
|
||||
ExpiresAt time.Time `bson:"expiresAt"`
|
||||
IsRevoked bool `bson:"isRevoked"`
|
||||
LastUsedAt time.Time `bson:"lastUsedAt,omitempty"`
|
||||
UserAgent string `bson:"userAgent"`
|
||||
IPAddress string `bson:"ipAddress"`
|
||||
}
|
||||
|
||||
func (*RefreshToken) Collection() string {
|
||||
return mservice.RefreshTokens
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
type SessionIdentifier struct {
|
||||
ClientID string `bson:"clientId" json:"clientId"`
|
||||
DeviceID string `bson:"deviceId" json:"deviceId"`
|
||||
SessionVersion string `bson:"sessionVersion,omitempty" json:"sessionVersion,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package refreshtokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
)
|
||||
|
||||
func (db *RefreshTokenDB) GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error) {
|
||||
var client sessionmodel.Client
|
||||
return &client, db.clients.FindOneByFilter(ctx, filterByClientId(clientID), &client)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package refreshtokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (db *RefreshTokenDB) Create(ctx context.Context, rt *sessionmodel.RefreshToken) error {
|
||||
if rt.LastUsedAt.IsZero() {
|
||||
rt.LastUsedAt = time.Now()
|
||||
}
|
||||
|
||||
if rt.AccountRef == nil {
|
||||
return merrors.InvalidArgument("Account reference must have a valid value", "refreshToken.accountRef")
|
||||
}
|
||||
|
||||
matched, err := db.replaceActive(ctx, rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if matched {
|
||||
return nil
|
||||
}
|
||||
|
||||
db.Logger.Debug("Registering refresh token", zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID))
|
||||
if err := db.DBImp.Create(ctx, rt); err != nil {
|
||||
if !errors.Is(err, merrors.ErrDataConflict) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Another login can establish the same active slot after the patch and
|
||||
// before this insert. Converge on that row instead of returning the
|
||||
// unique-index conflict to the caller.
|
||||
db.Logger.Debug("Concurrent refresh session registration detected",
|
||||
zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID))
|
||||
matched, reconcileErr := db.replaceActive(ctx, rt)
|
||||
if reconcileErr != nil {
|
||||
return reconcileErr
|
||||
}
|
||||
if !matched {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *RefreshTokenDB) replaceActive(ctx context.Context, rt *sessionmodel.RefreshToken) (bool, error) {
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field(TokenField), rt.RefreshToken).
|
||||
Set(repository.Field(ExpiresAtField), rt.ExpiresAt).
|
||||
Set(repository.Field(UserAgentField), rt.UserAgent).
|
||||
Set(repository.Field(IPAddressField), rt.IPAddress).
|
||||
Set(repository.Field(LastUsedAtField), rt.LastUsedAt).
|
||||
Set(repository.Field(IsRevokedField), rt.IsRevoked).
|
||||
Set(repository.Field(SessionVersionField), rt.SessionVersion)
|
||||
|
||||
var stored sessionmodel.RefreshToken
|
||||
matched, err := db.Repository.PatchOneByFilter(
|
||||
ctx,
|
||||
filterSlotByAccount(*rt.AccountRef, &rt.SessionIdentifier),
|
||||
patch,
|
||||
&stored,
|
||||
)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to replace active refresh session", zap.Error(err),
|
||||
zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID))
|
||||
return false, err
|
||||
}
|
||||
if !matched {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
db.Logger.Debug("Replaced active refresh session",
|
||||
zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID))
|
||||
rt.SetID(*stored.GetID())
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (db *RefreshTokenDB) Update(ctx context.Context, rt *sessionmodel.RefreshToken) error {
|
||||
rt.LastUsedAt = time.Now()
|
||||
|
||||
// Use Patch instead of Update to avoid race conditions
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field(TokenField), rt.RefreshToken).
|
||||
Set(repository.Field(ExpiresAtField), rt.ExpiresAt).
|
||||
Set(repository.Field(UserAgentField), rt.UserAgent).
|
||||
Set(repository.Field(IPAddressField), rt.IPAddress).
|
||||
Set(repository.Field(LastUsedAtField), rt.LastUsedAt).
|
||||
Set(repository.Field(IsRevokedField), rt.IsRevoked).
|
||||
Set(repository.Field(SessionVersionField), rt.SessionVersion)
|
||||
|
||||
return db.Patch(ctx, *rt.GetID(), patch)
|
||||
}
|
||||
|
||||
func (db *RefreshTokenDB) Delete(ctx context.Context, tokenRef bson.ObjectID) error {
|
||||
db.Logger.Debug("Deleting refresh token", mzap.ObjRef("refresh_token_ref", tokenRef))
|
||||
return db.DBImp.Delete(ctx, tokenRef)
|
||||
}
|
||||
|
||||
func (db *RefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) error {
|
||||
var rt sessionmodel.RefreshToken
|
||||
f := filterByAccount(accountRef, session)
|
||||
if err := db.Repository.FindOneByFilter(ctx, f, &rt); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
db.Logger.Debug("Refresh token not found during revoke",
|
||||
mzap.AccRef(accountRef), zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Use Patch to update the revocation status
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field(IsRevokedField), true).
|
||||
Set(repository.Field(LastUsedAtField), time.Now())
|
||||
|
||||
return db.Patch(ctx, *rt.GetID(), patch)
|
||||
}
|
||||
|
||||
func (db *RefreshTokenDB) RevokeToken(ctx context.Context, token *sessionmodel.ClientRefreshToken) (bool, error) {
|
||||
query := filter(&token.SessionIdentifier).
|
||||
And(repository.Query().Comparison(repository.Field(TokenField), builder.Eq, token.RefreshToken))
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field(IsRevokedField), true).
|
||||
Set(repository.Field(LastUsedAtField), time.Now())
|
||||
modified, err := db.Repository.PatchMany(ctx, query, patch)
|
||||
return modified > 0, err
|
||||
}
|
||||
|
||||
func (db *RefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error) {
|
||||
var rt sessionmodel.RefreshToken
|
||||
if err := db.Repository.FindOneByFilter(ctx, filterByAccount(accountRef, session), &rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !rt.ExpiresAt.After(time.Now()) {
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
return &rt, nil
|
||||
}
|
||||
|
||||
func (db *RefreshTokenDB) GetByCRT(ctx context.Context, t *sessionmodel.ClientRefreshToken) (*sessionmodel.RefreshToken, error) {
|
||||
var rt sessionmodel.RefreshToken
|
||||
f := filter(&t.SessionIdentifier).And(repository.Query().Filter(repository.Field("token"), t.RefreshToken))
|
||||
if err := db.Repository.FindOneByFilter(ctx, f, &rt); err != nil {
|
||||
if !errors.Is(err, merrors.ErrNoData) {
|
||||
db.Logger.Warn("Failed to fetch refresh token", zap.Error(err),
|
||||
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if rt.ExpiresAt.Before(time.Now()) {
|
||||
db.Logger.Debug("Refresh token expired", mzap.StorableRef(&rt),
|
||||
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID),
|
||||
zap.Time("expires_at", rt.ExpiresAt))
|
||||
return nil, merrors.AccessDenied(mservice.RefreshTokens, "read", *rt.GetID())
|
||||
}
|
||||
|
||||
// Check if token is revoked
|
||||
if rt.IsRevoked {
|
||||
db.Logger.Debug("Refresh token is revoked", mzap.StorableRef(&rt),
|
||||
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
if rt.GetID() != nil && !rt.GetID().IsZero() {
|
||||
lastUsedAt := time.Now()
|
||||
patch := repository.Patch().Set(repository.Field(LastUsedAtField), lastUsedAt)
|
||||
if err := db.Patch(ctx, *rt.GetID(), patch); err != nil {
|
||||
db.Logger.Warn("Failed to update refresh token last usage timestamp", zap.Error(err),
|
||||
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
|
||||
} else {
|
||||
rt.LastUsedAt = lastUsedAt
|
||||
}
|
||||
}
|
||||
|
||||
return &rt, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package refreshtokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type createRepository struct {
|
||||
repository.Repository
|
||||
filters []bson.D
|
||||
patches []bson.D
|
||||
patchMatches []bool
|
||||
patchErr error
|
||||
insertCalls int
|
||||
insertErr error
|
||||
storedID bson.ObjectID
|
||||
}
|
||||
|
||||
func (r *createRepository) PatchOneByFilter(
|
||||
_ context.Context,
|
||||
filter builder.Query,
|
||||
patch builder.Patch,
|
||||
result storable.Storable,
|
||||
) (bool, error) {
|
||||
r.filters = append(r.filters, filter.BuildQuery())
|
||||
r.patches = append(r.patches, patch.Build())
|
||||
if r.patchErr != nil {
|
||||
return false, r.patchErr
|
||||
}
|
||||
|
||||
call := len(r.filters) - 1
|
||||
matched := call < len(r.patchMatches) && r.patchMatches[call]
|
||||
if matched {
|
||||
result.SetID(r.storedID)
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (r *createRepository) Insert(_ context.Context, object storable.Storable, _ builder.Query) error {
|
||||
r.insertCalls++
|
||||
if object.GetID() == nil || object.GetID().IsZero() {
|
||||
object.SetID(bson.NewObjectID())
|
||||
}
|
||||
return r.insertErr
|
||||
}
|
||||
|
||||
func newCreateDB(repo repository.Repository) *RefreshTokenDB {
|
||||
return &RefreshTokenDB{
|
||||
DBImp: template.DBImp[*sessionmodel.RefreshToken]{
|
||||
Logger: zap.NewNop(),
|
||||
Repository: repo,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func versionedToken(accountRef bson.ObjectID, version, value string) *sessionmodel.RefreshToken {
|
||||
return &sessionmodel.RefreshToken{
|
||||
ClientRefreshToken: sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: "web-app",
|
||||
DeviceID: "browser",
|
||||
SessionVersion: version,
|
||||
},
|
||||
RefreshToken: value,
|
||||
},
|
||||
AccountRef: &accountRef,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
UserAgent: "test-agent",
|
||||
IPAddress: "192.0.2.1",
|
||||
}
|
||||
}
|
||||
|
||||
func extendedJSON(t *testing.T, document bson.D) string {
|
||||
t.Helper()
|
||||
raw, err := bson.MarshalExtJSON(document, true, false)
|
||||
require.NoError(t, err)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestCreateReplacesSlotWithoutMatchingNewSessionVersion(t *testing.T) {
|
||||
storedID := bson.NewObjectID()
|
||||
repo := &createRepository{
|
||||
patchMatches: []bool{true},
|
||||
storedID: storedID,
|
||||
}
|
||||
db := newCreateDB(repo)
|
||||
token := versionedToken(bson.NewObjectID(), "version-2", "replacement-token")
|
||||
|
||||
err := db.Create(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, storedID, *token.GetID())
|
||||
require.Zero(t, repo.insertCalls)
|
||||
require.Len(t, repo.filters, 1)
|
||||
|
||||
filterJSON := extendedJSON(t, repo.filters[0])
|
||||
require.Contains(t, filterJSON, "accountRef")
|
||||
require.Contains(t, filterJSON, "clientId")
|
||||
require.Contains(t, filterJSON, "deviceId")
|
||||
require.Contains(t, filterJSON, "isRevoked")
|
||||
require.NotContains(t, filterJSON, "sessionVersion")
|
||||
|
||||
patchJSON := extendedJSON(t, repo.patches[0])
|
||||
require.Contains(t, patchJSON, "sessionVersion")
|
||||
require.Contains(t, patchJSON, "version-2")
|
||||
}
|
||||
|
||||
func TestCreateReconcilesConcurrentSlotInsert(t *testing.T) {
|
||||
storedID := bson.NewObjectID()
|
||||
repo := &createRepository{
|
||||
patchMatches: []bool{false, true},
|
||||
insertErr: merrors.DataConflict("duplicate_key"),
|
||||
storedID: storedID,
|
||||
}
|
||||
db := newCreateDB(repo)
|
||||
token := versionedToken(bson.NewObjectID(), "version-2", "replacement-token")
|
||||
|
||||
err := db.Create(context.Background(), token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, storedID, *token.GetID())
|
||||
require.Equal(t, 1, repo.insertCalls)
|
||||
require.Len(t, repo.filters, 2)
|
||||
}
|
||||
|
||||
func TestCreatePreservesUnrelatedInsertConflict(t *testing.T) {
|
||||
repo := &createRepository{
|
||||
patchMatches: []bool{false, false},
|
||||
insertErr: merrors.DataConflict("duplicate_key"),
|
||||
storedID: bson.NewObjectID(),
|
||||
}
|
||||
db := newCreateDB(repo)
|
||||
token := versionedToken(bson.NewObjectID(), "version-2", "replacement-token")
|
||||
|
||||
err := db.Create(context.Background(), token)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, merrors.ErrDataConflict))
|
||||
require.Equal(t, 1, repo.insertCalls)
|
||||
require.Len(t, repo.filters, 2)
|
||||
}
|
||||
|
||||
func TestCredentialFilterKeepsSessionVersion(t *testing.T) {
|
||||
query := filterByAccount(bson.NewObjectID(), &sessionmodel.SessionIdentifier{
|
||||
ClientID: "web-app",
|
||||
DeviceID: "browser",
|
||||
SessionVersion: "version-2",
|
||||
})
|
||||
|
||||
filterJSON := extendedJSON(t, query.BuildQuery())
|
||||
require.Contains(t, filterJSON, "sessionVersion")
|
||||
require.Contains(t, filterJSON, "version-2")
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package refreshtokens
|
||||
|
||||
import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Collections struct {
|
||||
RefreshTokens string
|
||||
Clients string
|
||||
}
|
||||
|
||||
type RefreshTokenDB struct {
|
||||
template.DBImp[*sessionmodel.RefreshToken]
|
||||
clients repository.Repository
|
||||
collection Collections
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error) {
|
||||
return CreateWithCollections(logger, db, Collections{})
|
||||
}
|
||||
|
||||
func (c Collections) WithDefaults() Collections {
|
||||
if strings.TrimSpace(c.RefreshTokens) == "" {
|
||||
c.RefreshTokens = mservice.RefreshTokens
|
||||
}
|
||||
if strings.TrimSpace(c.Clients) == "" {
|
||||
c.Clients = mservice.Clients
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func CreateWithCollections(logger mlogger.Logger, db *mongo.Database, collections Collections) (*RefreshTokenDB, error) {
|
||||
collections = collections.WithDefaults()
|
||||
p := &RefreshTokenDB{
|
||||
DBImp: *template.Create[*sessionmodel.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db),
|
||||
clients: repository.CreateMongoRepository(db, collections.Clients),
|
||||
collection: collections,
|
||||
}
|
||||
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: "token", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
}); err != nil {
|
||||
p.Logger.Error("Failed to create unique token index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ttl := int32(0)
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: ExpiresAtField, Sort: ri.Asc}},
|
||||
Name: "expire_sessions",
|
||||
TTL: &ttl,
|
||||
}); err != nil {
|
||||
p.Logger.Error("Failed to create refresh session expiration index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add unique constraint on account/client/device combination
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "accountRef", Sort: ri.Asc},
|
||||
{Field: "clientId", Sort: ri.Asc},
|
||||
{Field: "deviceId", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
Name: "unique_active_session",
|
||||
PartialFilter: repository.Filter(IsRevokedField, false),
|
||||
}); err != nil {
|
||||
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: IsRevokedField, Sort: ri.Asc}},
|
||||
}); err != nil {
|
||||
p.Logger.Error("Failed to create unique token revokation status index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := p.clients.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: "clientId", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
}); err != nil {
|
||||
p.Logger.Error("Failed to create unique client identifier index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (db *RefreshTokenDB) Collections() Collections {
|
||||
return db.collection
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package refreshtokens
|
||||
|
||||
const (
|
||||
ExpiresAtField = "expiresAt"
|
||||
IsRevokedField = "isRevoked"
|
||||
TokenField = "token"
|
||||
UserAgentField = "userAgent"
|
||||
IPAddressField = "ipAddress"
|
||||
LastUsedAtField = "lastUsedAt"
|
||||
SessionVersionField = "sessionVersion"
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package refreshtokens
|
||||
|
||||
import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func filterByClientId(clientID string) builder.Query {
|
||||
return repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID)
|
||||
}
|
||||
|
||||
func filterSlot(session *sessionmodel.SessionIdentifier) builder.Query {
|
||||
filter := filterByClientId(session.ClientID)
|
||||
filter.And(
|
||||
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, session.DeviceID),
|
||||
repository.Query().Comparison(repository.Field(IsRevokedField), builder.Eq, false),
|
||||
)
|
||||
return filter
|
||||
}
|
||||
|
||||
func filter(session *sessionmodel.SessionIdentifier) builder.Query {
|
||||
filter := filterSlot(session)
|
||||
if session.SessionVersion != "" {
|
||||
filter.And(repository.Query().Comparison(repository.Field("sessionVersion"), builder.Eq, session.SessionVersion))
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func filterByAccount(accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) builder.Query {
|
||||
return filter(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef))
|
||||
}
|
||||
|
||||
func filterSlotByAccount(accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) builder.Query {
|
||||
return filterSlot(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package refreshtokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type DB interface {
|
||||
template.DB[*sessionmodel.RefreshToken]
|
||||
Revoke(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) error
|
||||
RevokeToken(ctx context.Context, token *sessionmodel.ClientRefreshToken) (bool, error)
|
||||
RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error
|
||||
GetSession(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error)
|
||||
GetByCRT(ctx context.Context, t *sessionmodel.ClientRefreshToken) (*sessionmodel.RefreshToken, error)
|
||||
GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package refreshtokens_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Unique_Token_Constraint", func(t *testing.T) {
|
||||
userID1 := bson.NewObjectID()
|
||||
userID2 := bson.NewObjectID()
|
||||
token := "duplicate_token_123"
|
||||
|
||||
refreshToken1 := createTestRefreshToken(userID1, "client1", "device1", token)
|
||||
err := db.Create(ctx, refreshToken1)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken2 := createTestRefreshToken(userID2, "client2", "device2", token)
|
||||
err = db.Create(ctx, refreshToken2)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "duplicate")
|
||||
})
|
||||
|
||||
t.Run("Query_Performance_By_Revocation_Status", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
token := createTestRefreshToken(userID, clientID,
|
||||
fmt.Sprintf("device_%d", i), fmt.Sprintf("token_%d", i))
|
||||
if i%2 == 0 {
|
||||
token.IsRevoked = true
|
||||
}
|
||||
err := db.Create(ctx, token)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
query := repository.Query().
|
||||
Filter(repository.AccountField(), userID).
|
||||
And(repository.Query().Comparison(repository.Field(refreshtokens.IsRevokedField), builder.Eq, false))
|
||||
|
||||
ids, err := db.ListIDs(ctx, query)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ids, 5)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) {
|
||||
db, database, cleanup := setupTestDBWithMongo(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx)
|
||||
require.NoError(t, err)
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
found := false
|
||||
for cursor.Next(ctx) {
|
||||
var idx bson.M
|
||||
require.NoError(t, cursor.Decode(&idx))
|
||||
if idx["name"] == "unique_active_session" {
|
||||
found = true
|
||||
assert.Equal(t, true, idx["unique"])
|
||||
|
||||
partialRaw, err := bson.Marshal(idx["partialFilterExpression"])
|
||||
require.NoError(t, err)
|
||||
|
||||
var partial bson.M
|
||||
err = bson.Unmarshal(partialRaw, &partial)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, bson.M{"isRevoked": false}, partial)
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "unique_active_session index not found")
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package refreshtokens_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestRefreshTokenDB_ClientManagement(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Client_CRUD_Operations", func(t *testing.T) {
|
||||
// Note: Client management is handled by a separate client database.
|
||||
// This test verifies that refresh tokens work with different client IDs.
|
||||
userID := bson.NewObjectID()
|
||||
|
||||
webToken := createTestRefreshToken(userID, "web-app", "device1", "token1")
|
||||
err := db.Create(ctx, webToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
mobileToken := createTestRefreshToken(userID, "mobile-app", "device2", "token2")
|
||||
err = db.Create(ctx, mobileToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
webCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: "web-app",
|
||||
DeviceID: "device1",
|
||||
},
|
||||
RefreshToken: "token1",
|
||||
}
|
||||
|
||||
retrievedToken, err := db.GetByCRT(ctx, webCRT)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "web-app", retrievedToken.ClientID)
|
||||
assert.Equal(t, "device1", retrievedToken.DeviceID)
|
||||
|
||||
mobileCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: "mobile-app",
|
||||
DeviceID: "device2",
|
||||
},
|
||||
RefreshToken: "token2",
|
||||
}
|
||||
|
||||
retrievedToken, err = db.GetByCRT(ctx, mobileCRT)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mobile-app", retrievedToken.ClientID)
|
||||
assert.Equal(t, "device2", retrievedToken.DeviceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshTokenDB_SecurityScenarios(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Token_Hijacking_Prevention", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "user-browser"
|
||||
token := "hijacked_token_123"
|
||||
|
||||
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
|
||||
err := db.Create(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
session := &sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
err = db.Revoke(ctx, userID, session)
|
||||
require.NoError(t, err)
|
||||
|
||||
crt := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: token,
|
||||
}
|
||||
|
||||
_, err = db.GetByCRT(ctx, crt)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrNoData))
|
||||
})
|
||||
|
||||
t.Run("Invalid_Token_Attempts", func(t *testing.T) {
|
||||
crt := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: "invalid-client",
|
||||
DeviceID: "invalid-device",
|
||||
},
|
||||
RefreshToken: "invalid_token_123",
|
||||
}
|
||||
|
||||
_, err := db.GetByCRT(ctx, crt)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrNoData))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshTokenDB_ExpiredTokenHandling(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Expired_Token_Cleanup", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "user-device"
|
||||
token := "expired_token_123"
|
||||
|
||||
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
|
||||
refreshToken.ExpiresAt = time.Now().Add(-1 * time.Hour)
|
||||
err := db.Create(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
var storedToken sessionmodel.RefreshToken
|
||||
err = db.Get(ctx, *refreshToken.GetID(), &storedToken)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, storedToken.ExpiresAt.Before(time.Now()))
|
||||
|
||||
crt := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: token,
|
||||
}
|
||||
|
||||
_, err = db.GetByCRT(ctx, crt)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrAccessDenied))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshTokenDB_ConcurrentAccess(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Concurrent_Token_Usage", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "user-device"
|
||||
token := "concurrent_token_123"
|
||||
|
||||
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
|
||||
err := db.Create(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
crt := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: token,
|
||||
}
|
||||
|
||||
done := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
_, err := db.GetByCRT(ctx, crt)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_, err := db.GetByCRT(ctx, crt)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
err := <-done
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshTokenDB_EdgeCases(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Delete_Token_By_ID", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
refreshToken := createTestRefreshToken(userID, "web-app", "device-1", "token_123")
|
||||
err := db.Create(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenID := *refreshToken.GetID()
|
||||
|
||||
err = db.Delete(ctx, tokenID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result sessionmodel.RefreshToken
|
||||
err = db.Get(ctx, tokenID, &result)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrNoData))
|
||||
})
|
||||
|
||||
t.Run("Revoke_Non_Existent_Token", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
session := &sessionmodel.SessionIdentifier{
|
||||
ClientID: "non-existent-client",
|
||||
DeviceID: "non-existent-device",
|
||||
}
|
||||
|
||||
err := db.Revoke(ctx, userID, session)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("RevokeAll_No_Other_Devices", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "only-device"
|
||||
|
||||
refreshToken := createTestRefreshToken(userID, clientID, deviceID, "token_123")
|
||||
err := db.Create(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.RevokeAll(ctx, userID, deviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
crt := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: "token_123",
|
||||
}
|
||||
|
||||
_, err = db.GetByCRT(ctx, crt)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package refreshtokens_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestRefreshTokenDB_SessionReplacement(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("User_Login_From_Same_Device_Twice", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "user-laptop"
|
||||
|
||||
// First login
|
||||
firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_token_123")
|
||||
firstToken.SessionVersion = "session-version-1"
|
||||
err := db.Create(ctx, firstToken)
|
||||
require.NoError(t, err)
|
||||
firstTokenID := *firstToken.GetID()
|
||||
|
||||
// Second login from same device - should replace existing token
|
||||
secondToken := createTestRefreshToken(userID, clientID, deviceID, "second_token_456")
|
||||
secondToken.SessionVersion = "session-version-2"
|
||||
err = db.Create(ctx, secondToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should reuse the same database record
|
||||
assert.Equal(t, firstTokenID, *secondToken.GetID())
|
||||
|
||||
// First token should no longer work
|
||||
firstCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
SessionVersion: "session-version-1",
|
||||
},
|
||||
RefreshToken: "first_token_123",
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, firstCRT)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Second token should work
|
||||
secondCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
SessionVersion: "session-version-2",
|
||||
},
|
||||
RefreshToken: "second_token_456",
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, secondCRT)
|
||||
require.NoError(t, err)
|
||||
|
||||
secondCRT.SessionVersion = "session-version-1"
|
||||
_, err = db.GetByCRT(ctx, secondCRT)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "user-laptop"
|
||||
|
||||
firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123")
|
||||
err := db.Create(ctx, firstToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, firstToken.GetID())
|
||||
|
||||
// Global revoke (deviceID empty) - all tokens should be revoked
|
||||
err = db.RevokeAll(ctx, userID, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
var revoked sessionmodel.RefreshToken
|
||||
err = db.Get(ctx, *firstToken.GetID(), &revoked)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, revoked.IsRevoked)
|
||||
|
||||
// Creating a new token for the same account/client/device must succeed and produce an active token
|
||||
reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation")
|
||||
err = db.Create(ctx, reissueToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
newCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: "new_token_after_revocation",
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, newCRT)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Old token must remain unusable
|
||||
oldCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: "revoked_token_123",
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, oldCRT)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Both records exist: revoked + new active
|
||||
query := repository.Query().
|
||||
Filter(repository.AccountField(), userID).
|
||||
And(
|
||||
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
|
||||
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
|
||||
)
|
||||
ids, err := db.Repository.ListIDs(ctx, query)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ids, 2)
|
||||
})
|
||||
|
||||
t.Run("User_Can_Login_Again_After_Session_Revoke", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "user-laptop"
|
||||
|
||||
firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_login_token")
|
||||
err := db.Create(ctx, firstToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, firstToken.GetID())
|
||||
firstTokenID := *firstToken.GetID()
|
||||
|
||||
session := &sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
err = db.Revoke(ctx, userID, session)
|
||||
require.NoError(t, err)
|
||||
|
||||
var revoked sessionmodel.RefreshToken
|
||||
err = db.Get(ctx, firstTokenID, &revoked)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, revoked.IsRevoked)
|
||||
|
||||
secondToken := createTestRefreshToken(userID, clientID, deviceID, "second_login_token")
|
||||
err = db.Create(ctx, secondToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, secondToken.GetID())
|
||||
assert.NotEqual(t, firstTokenID, *secondToken.GetID())
|
||||
|
||||
firstCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: "first_login_token",
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, firstCRT)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrNoData))
|
||||
|
||||
secondCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: "second_login_token",
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, secondCRT)
|
||||
require.NoError(t, err)
|
||||
|
||||
query := repository.Query().
|
||||
Filter(repository.AccountField(), userID).
|
||||
And(
|
||||
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
|
||||
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
|
||||
)
|
||||
ids, err := db.Repository.ListIDs(ctx, query)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ids, 2)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package refreshtokens_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRefreshTokenDB_TokenRotation(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Token_Rotation_On_Use", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "user-browser"
|
||||
initialToken := "initial_token_123"
|
||||
|
||||
// Create initial token
|
||||
refreshToken := createTestRefreshToken(userID, clientID, deviceID, initialToken)
|
||||
err := db.Create(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate small delay
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Use token - should update LastUsedAt
|
||||
crt := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: initialToken,
|
||||
}
|
||||
|
||||
retrievedToken, err := db.GetByCRT(ctx, crt)
|
||||
require.NoError(t, err)
|
||||
// Token lookup should preserve data and advance LastUsedAt.
|
||||
assert.Equal(t, initialToken, retrievedToken.RefreshToken)
|
||||
|
||||
// Create new token with rotated value (simulating token rotation)
|
||||
newToken := "rotated_token_456"
|
||||
retrievedToken.RefreshToken = newToken
|
||||
err = db.Update(ctx, retrievedToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Old token should no longer work
|
||||
_, err = db.GetByCRT(ctx, crt)
|
||||
assert.Error(t, err)
|
||||
|
||||
// New token should work
|
||||
newCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: newToken,
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, newCRT)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Create_Defaults_LastUsedAt_When_Replacing_Active_Session", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "replacement-browser"
|
||||
|
||||
initialLastUsedAt := time.Now().Add(-2 * time.Hour)
|
||||
firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_session_token")
|
||||
firstToken.LastUsedAt = initialLastUsedAt
|
||||
err := db.Create(ctx, firstToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, firstToken.GetID())
|
||||
firstTokenID := *firstToken.GetID()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
replacement := &sessionmodel.RefreshToken{
|
||||
ClientRefreshToken: sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: "replacement_session_token",
|
||||
},
|
||||
AccountRef: &userID,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
IsRevoked: false,
|
||||
UserAgent: "TestUserAgent/2.0",
|
||||
IPAddress: "10.0.0.5",
|
||||
}
|
||||
|
||||
err = db.Create(ctx, replacement)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, replacement.GetID())
|
||||
assert.Equal(t, firstTokenID, *replacement.GetID())
|
||||
|
||||
var stored sessionmodel.RefreshToken
|
||||
err = db.Get(ctx, firstTokenID, &stored)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, stored.LastUsedAt.IsZero())
|
||||
assert.True(t, stored.LastUsedAt.After(initialLastUsedAt))
|
||||
assert.Equal(t, "replacement_session_token", stored.RefreshToken)
|
||||
})
|
||||
|
||||
t.Run("GetByCRT_Updates_LastUsedAt", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "refresh-browser"
|
||||
token := "refresh_token_last_used"
|
||||
|
||||
initialLastUsedAt := time.Now().Add(-1 * time.Hour)
|
||||
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
|
||||
refreshToken.LastUsedAt = initialLastUsedAt
|
||||
err := db.Create(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, refreshToken.GetID())
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
crt := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: token,
|
||||
}
|
||||
|
||||
retrievedToken, err := db.GetByCRT(ctx, crt)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, retrievedToken.LastUsedAt.After(initialLastUsedAt))
|
||||
|
||||
var stored sessionmodel.RefreshToken
|
||||
err = db.Get(ctx, *refreshToken.GetID(), &stored)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, stored.LastUsedAt.After(initialLastUsedAt))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package refreshtokens_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
factory "git.sendico.io/tech/sxchnge/shared/logging/factory"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) (*refreshtokens.RefreshTokenDB, func()) {
|
||||
db, _, cleanup := setupTestDBWithMongo(t)
|
||||
return db, cleanup
|
||||
}
|
||||
|
||||
func setupTestDBWithMongo(t *testing.T) (*refreshtokens.RefreshTokenDB, *mongo.Database, func()) {
|
||||
// mark as helper for better test failure reporting
|
||||
t.Helper()
|
||||
|
||||
startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer startCancel()
|
||||
|
||||
mongoContainer, err := mongodb.Run(startCtx,
|
||||
"mongo:latest",
|
||||
mongodb.WithUsername("root"),
|
||||
mongodb.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
|
||||
)
|
||||
require.NoError(t, err, "failed to start MongoDB container")
|
||||
|
||||
mongoURI, err := mongoContainer.ConnectionString(startCtx)
|
||||
require.NoError(t, err, "failed to get MongoDB connection string")
|
||||
|
||||
clientOptions := options.Client().ApplyURI(mongoURI)
|
||||
client, err := mongo.Connect(clientOptions)
|
||||
require.NoError(t, err, "failed to connect to MongoDB")
|
||||
|
||||
database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name()))))
|
||||
logger := factory.NewLogger(true)
|
||||
|
||||
db, err := refreshtokens.Create(logger, database)
|
||||
require.NoError(t, err, "failed to create refresh tokens db")
|
||||
|
||||
cleanup := func() {
|
||||
_ = database.Drop(context.Background())
|
||||
_ = client.Disconnect(context.Background())
|
||||
termCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = mongoContainer.Terminate(termCtx)
|
||||
}
|
||||
|
||||
return db, database, cleanup
|
||||
}
|
||||
|
||||
func createTestRefreshToken(accountRef bson.ObjectID, clientID, deviceID, token string) *sessionmodel.RefreshToken {
|
||||
return &sessionmodel.RefreshToken{
|
||||
ClientRefreshToken: sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: token,
|
||||
},
|
||||
AccountRef: &accountRef,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
IsRevoked: false,
|
||||
UserAgent: "TestUserAgent/1.0",
|
||||
IPAddress: "192.168.1.1",
|
||||
LastUsedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenDB_AuthenticationFlow(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Complete_User_Authentication_Flow", func(t *testing.T) {
|
||||
// Setup: Create user and client
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "user-desktop-chrome"
|
||||
token := "refresh_token_12345"
|
||||
|
||||
// Step 1: User logs in - create initial refresh token
|
||||
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
|
||||
err := db.Create(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 2: User uses refresh token to get new access token
|
||||
crt := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: token,
|
||||
}
|
||||
|
||||
retrievedToken, err := db.GetByCRT(ctx, crt)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedToken.AccountRef)
|
||||
assert.Equal(t, userID, *retrievedToken.AccountRef)
|
||||
assert.Equal(t, token, retrievedToken.RefreshToken)
|
||||
assert.False(t, retrievedToken.IsRevoked)
|
||||
|
||||
// Step 3: User logs out - revoke the token
|
||||
session := &sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
err = db.Revoke(ctx, userID, session)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 4: Try to use revoked token - should fail
|
||||
_, err = db.GetByCRT(ctx, crt)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrNoData))
|
||||
})
|
||||
|
||||
t.Run("Manual_Token_Revocation_Workaround", func(t *testing.T) {
|
||||
// Test manual revocation by directly updating the token
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "web-app"
|
||||
deviceID := "user-desktop-chrome"
|
||||
token := "manual_revoke_token_123"
|
||||
|
||||
// Step 1: Create token
|
||||
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
|
||||
err := db.Create(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 2: Manually revoke token by updating it directly
|
||||
refreshToken.IsRevoked = true
|
||||
err = db.Update(ctx, refreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 3: Try to use revoked token - should fail
|
||||
crt := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: token,
|
||||
}
|
||||
|
||||
_, err = db.GetByCRT(ctx, crt)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrNoData))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshTokenDB_MultiDeviceManagement(t *testing.T) {
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("User_With_Multiple_Devices", func(t *testing.T) {
|
||||
userID := bson.NewObjectID()
|
||||
clientID := "mobile-app"
|
||||
|
||||
// User logs in from phone
|
||||
phoneToken := createTestRefreshToken(userID, clientID, "phone-ios", "phone_token_123")
|
||||
err := db.Create(ctx, phoneToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// User logs in from tablet
|
||||
tabletToken := createTestRefreshToken(userID, clientID, "tablet-android", "tablet_token_456")
|
||||
err = db.Create(ctx, tabletToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// User logs in from desktop
|
||||
desktopToken := createTestRefreshToken(userID, clientID, "desktop-windows", "desktop_token_789")
|
||||
err = db.Create(ctx, desktopToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// User wants to logout from all devices except current (phone)
|
||||
err = db.RevokeAll(ctx, userID, "phone-ios")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Phone should still work
|
||||
phoneCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: "phone-ios",
|
||||
},
|
||||
RefreshToken: "phone_token_123",
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, phoneCRT)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tablet and desktop should be revoked
|
||||
tabletCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: "tablet-android",
|
||||
},
|
||||
RefreshToken: "tablet_token_456",
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, tabletCRT)
|
||||
assert.Error(t, err)
|
||||
|
||||
desktopCRT := &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: "desktop-windows",
|
||||
},
|
||||
RefreshToken: "desktop_token_789",
|
||||
}
|
||||
_, err = db.GetByCRT(ctx, desktopCRT)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package refreshtokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *RefreshTokenDB) RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error {
|
||||
query := repository.Query().
|
||||
Filter(repository.AccountField(), accountRef).
|
||||
And(repository.Query().Comparison(repository.Field("deviceId"), builder.Ne, deviceID)).
|
||||
And(repository.Query().Comparison(repository.Field(IsRevokedField), builder.Eq, false))
|
||||
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field(ExpiresAtField), time.Now()).
|
||||
Set(repository.Field(IsRevokedField), true)
|
||||
|
||||
_, err := db.Repository.PatchMany(ctx, query, patch)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package tokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type recordingRefreshTokenDB struct {
|
||||
refreshtokens.DB
|
||||
token *sessionmodel.RefreshToken
|
||||
}
|
||||
|
||||
func (db *recordingRefreshTokenDB) Create(_ context.Context, token *sessionmodel.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 := &customermodel.Account{}
|
||||
account.SetID(bson.NewObjectID())
|
||||
db := &recordingRefreshTokenDB{}
|
||||
_, err := PrepareRefreshToken(
|
||||
context.Background(),
|
||||
request,
|
||||
db,
|
||||
32,
|
||||
24,
|
||||
&sessionmodel.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,78 @@
|
||||
package tokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"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 *sessionmodel.SessionIdentifier,
|
||||
account *customermodel.Account,
|
||||
logger mlogger.Logger,
|
||||
) (*sessionmodel.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 := &sessionmodel.RefreshToken{
|
||||
AccountRef: account.GetID(),
|
||||
ClientRefreshToken: sessionmodel.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