feat(sessions): add support for remote redis session store (#3345)

Description
====================
zot currently stores session cookies in memory or in a local directory.
For cases where the session cookies should be independent of the
instance where they were created such as multiple instances of zot, or a
fully stateless zot instance, there is a need to support a remote
session storage.
This change adds support for using Redis and Redis-compatible services as a
remote session driver as well as introduces a new configuration option
for it.

What has changed
=======================
- New config added under Auth config to specify configuration for
  the session driver.
- Examples README updated with details of the new Auth config.
- The config supports only 2 drivers in this change - local and redis
- Using the local driver is backwards compatible and behaves the same
  way that zot currently works for local session storage.
- Omitting this config does not result in an error. In this case, zot
  behaves as it normally does for local session storage.
- When configured, zot can use redis for persisting cookie
  information for zot UI.
- The cookie in the store is deleted on logout or after the max
  expiry time for the cookie.
- Configuration for the redis session driver accepts the same configuration
  values as that of the remote meta cache.
- A separate connection is established for the session driver. An
  existing connection for meta cache will not be re-used for the
  session driver.
- A key prefix is configurable for the redis session driver. The value will be
  converted into a string for use. If no value is provided, a default
  prefix of "zotsession" will be used.
- Redis sessions does not support hash key or encryption in this change.
- New BATS test added to verify zot behavior with Redis session store.
- Github workflow updated to install valkey-tools dependency for BATS.

Signed-off-by: Vishwas Rajashekar <dev@vrajashkr.com>
This commit is contained in:
Vishwas Rajashekar
2025-10-05 12:43:38 +05:30
committed by GitHub
parent cbbd39745c
commit 86af38abfc
16 changed files with 1342 additions and 48 deletions
+161 -27
View File
@@ -15,11 +15,14 @@ import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
guuid "github.com/gofrs/uuid"
godigest "github.com/opencontainers/go-digest"
"github.com/project-zot/mockoidc"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/resty.v1"
zerr "zotregistry.dev/zot/errors"
"zotregistry.dev/zot/pkg/api"
"zotregistry.dev/zot/pkg/api/config"
"zotregistry.dev/zot/pkg/api/constants"
@@ -934,45 +937,65 @@ func TestCookiestoreCleanup(t *testing.T) {
log := log.NewTestLogger()
metrics := monitoring.NewMetricsServer(true, log)
Convey("Test cookiestore cleanup works", t, func() {
taskScheduler := scheduler.NewScheduler(config.New(), metrics, log)
taskScheduler.RateLimit = 50 * time.Millisecond
taskScheduler.RunScheduler()
authCfgTestCases := []struct {
name string
cfg config.AuthConfig
}{
{
"empty Auth config",
config.AuthConfig{},
},
{
"local session driver",
config.AuthConfig{
SessionDriver: map[string]any{
"name": "local",
},
},
},
}
rootDir := t.TempDir()
for _, testCase := range authCfgTestCases {
Convey("Test cookiestore cleanup works with "+testCase.name, t, func() {
taskScheduler := scheduler.NewScheduler(config.New(), metrics, log)
taskScheduler.RateLimit = 50 * time.Millisecond
taskScheduler.RunScheduler()
err := os.MkdirAll(path.Join(rootDir, "_sessions"), storageConstants.DefaultDirPerms)
So(err, ShouldBeNil)
rootDir := t.TempDir()
sessionPath := path.Join(rootDir, "_sessions", "session_1234")
err := os.MkdirAll(path.Join(rootDir, "_sessions"), storageConstants.DefaultDirPerms)
So(err, ShouldBeNil)
err = os.WriteFile(sessionPath, []byte("session"), storageConstants.DefaultFilePerms)
So(err, ShouldBeNil)
sessionPath := path.Join(rootDir, "_sessions", "session_1234")
changeTime := time.Now().Add(-4 * time.Hour)
err = os.WriteFile(sessionPath, []byte("session"), storageConstants.DefaultFilePerms)
So(err, ShouldBeNil)
err = os.Chtimes(sessionPath, changeTime, changeTime)
So(err, ShouldBeNil)
changeTime := time.Now().Add(-4 * time.Hour)
imgStore := local.NewImageStore(rootDir, false, false, log, metrics, nil, nil, nil, nil)
err = os.Chtimes(sessionPath, changeTime, changeTime)
So(err, ShouldBeNil)
storeController := storage.StoreController{
DefaultStore: imgStore,
}
imgStore := local.NewImageStore(rootDir, false, false, log, metrics, nil, nil, nil, nil)
cookieStore, err := api.NewCookieStore(storeController, nil, nil)
So(err, ShouldBeNil)
storeController := storage.StoreController{
DefaultStore: imgStore,
}
cookieStore.RunSessionCleaner(taskScheduler)
cookieStore, err := api.NewCookieStore(&testCase.cfg, storeController, log)
So(err, ShouldBeNil)
time.Sleep(2 * time.Second)
cookieStore.RunSessionCleaner(taskScheduler)
taskScheduler.Shutdown()
time.Sleep(2 * time.Second)
// make sure session is removed
_, err = os.Stat(sessionPath)
So(err, ShouldNotBeNil)
})
taskScheduler.Shutdown()
// make sure session is removed
_, err = os.Stat(sessionPath)
So(err, ShouldNotBeNil)
})
}
Convey("Test cookiestore cleanup without permissions on rootDir", t, func() {
taskScheduler := scheduler.NewScheduler(config.New(), metrics, log)
@@ -995,7 +1018,11 @@ func TestCookiestoreCleanup(t *testing.T) {
DefaultStore: imgStore,
}
cookieStore, err := api.NewCookieStore(storeController, []byte("secret"), nil)
authCfg := config.AuthConfig{
SessionHashKey: []byte("secret"),
}
cookieStore, err := api.NewCookieStore(&authCfg, storeController, log)
So(err, ShouldBeNil)
err = os.Chmod(rootDir, 0o000)
@@ -1085,6 +1112,113 @@ func TestCookiestoreCleanup(t *testing.T) {
})
}
func TestRedisCookieStore(t *testing.T) {
log := log.Logger{}
testRedis := miniredis.RunT(t)
storeController := storage.StoreController{
DefaultStore: &mocks.MockedImageStore{
GetImageManifestFn: func(repo string, reference string) ([]byte, godigest.Digest, string, error) {
return []byte{}, "", "", zerr.ErrRepoBadVersion
},
},
}
testCases := []struct {
testName string
shouldErrBeNil bool
expectedErrStr string
inputCfg *config.AuthConfig
}{
{
"Cookie store creation should not fail if the driver is local",
true,
"",
&config.AuthConfig{
SessionDriver: map[string]any{
"name": "local",
},
},
},
{
"Cookie store creation should fail if the driver is unsupported",
false,
"invalid server config: sessiondriver unknowndriver not supported",
&config.AuthConfig{
SessionDriver: map[string]any{
"name": "unknowndriver",
},
},
},
{
"Cookie store creation should not fail if the keyPrefix for Redis is not a string",
true,
"",
&config.AuthConfig{
SessionDriver: map[string]any{
"name": "redis",
"keyprefix": 8,
"url": "redis://" + testRedis.Addr(),
},
},
},
{
"Cookie store creation should not fail if the SessionDriver Config is nil",
true,
"",
&config.AuthConfig{},
},
{
"Cookie store creation and use should succeed with valid configuration",
true,
"",
&config.AuthConfig{
SessionDriver: map[string]any{
"name": "redis",
"url": "redis://" + testRedis.Addr(),
},
},
},
{
"Cookie store creation should fail if the url for Redis is incorrect",
false,
"dial tcp: lookup unknown on 127.0.0.53:53: server misbehaving",
&config.AuthConfig{
SessionDriver: map[string]any{
"name": "redis",
"url": "redis://unknown:1000",
},
},
},
{
"Cookie store creation should fail if the url for Redis has an invalid value",
false,
"invalid server config: cachedriver map[name:redis url:%!s(int=100)] has invalid value for url",
&config.AuthConfig{
SessionDriver: map[string]any{
"name": "redis",
"url": 100,
},
},
},
}
for _, testCase := range testCases {
Convey(testCase.testName, t, func() {
cookieStore, err := api.NewCookieStore(testCase.inputCfg, storeController, log)
if testCase.shouldErrBeNil {
So(err, ShouldBeNil)
So(cookieStore, ShouldNotBeNil)
} else {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, testCase.expectedErrStr)
So(cookieStore, ShouldBeNil)
}
})
}
}
type mockUUIDGenerator struct {
guuid.Generator
succeedAttempts int