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
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
go install github.com/swaggo/swag/cmd/swag@v1.16.2
go mod download
sudo apt-get update
sudo apt-get install libgpgme-dev libassuan-dev libbtrfs-dev libdevmapper-dev pkg-config rpm uidmap haproxy jq
sudo apt-get install libgpgme-dev libassuan-dev libbtrfs-dev libdevmapper-dev pkg-config rpm uidmap haproxy jq valkey-tools
# install skopeo
git clone -b v1.12.0 https://github.com/containers/skopeo.git
cd skopeo
+39
View File
@@ -370,6 +370,45 @@ Using that cookie on subsequent calls will authenticate them, asumming the cooki
In case of using filesystem storage sessions are saved in zot's root directory.
In case of using cloud storage sessions are saved in memory.
Note: By default, the session driver config would be local for file system or in-memory. The session driver name for this is `local`. An example config is shown below, but the config can be omitted as it is a default.
```
"auth": {
"htpasswd": {
"path": "test/data/htpasswd"
},
"sessionDriver": {
"name": "local"
}
}
```
Note: This `sessionDriver` config is optional if a local session storage is desired.
#### Remote Session Storage Driver
Redis and Redis-compatible storage drivers can also be used for cases where session storage is required to be kept separately from zot or multiple zot instances need to share the session information.
This can be configured in the `auth` section of the configuration as shown below:
`sessionDriver`
```
"auth": {
"htpasswd": {
"path": "test/data/htpasswd"
},
"sessionDriver": {
"name": "redis",
"url": "redis://localhost:6379",
"keyprefix": "zotsession"
}
}
```
The `redis` driver configuration options are the same as those in the [Redis Cache Driver](#redis) section. If the `redis` session driver is being used along with a `redis` cache driver and both configurations point to the same Redis instance, there will be two independent connections used.
Note: The `redis` session driver cannot be specified along with configuration for the SessionKeysFile.
### Securing session based login
@@ -0,0 +1,34 @@
{
"distSpecVersion": "1.1.1",
"storage": {
"rootDirectory": "/tmp/zot"
},
"http": {
"address": "127.0.0.1",
"port": "8080",
"realm": "zot",
"auth": {
"htpasswd": {
"path": "/tmp/zotpasswd"
},
"sessionDriver": {
"name": "redis",
"url": "redis://localhost:6379",
"keyprefix": "zotsession"
}
}
},
"log": {
"level": "debug"
},
"extensions": {
"search": {
"cve": {
"updateInterval": "2h"
}
},
"ui": {
"enable": true
}
}
}
+1
View File
@@ -56,6 +56,7 @@ require (
github.com/project-zot/mockoidc v0.0.0-20240610203808-d69d9e02020a
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/rbcervilla/redisstore/v9 v9.0.0
github.com/redis/go-redis/v9 v9.14.0
github.com/regclient/regclient v0.9.2
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
+2
View File
@@ -1873,6 +1873,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d h1:HWfigq7lB31IeJL8iy7jkUmU/PG1Sr8jVGhS749dbUA=
github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
github.com/rbcervilla/redisstore/v9 v9.0.0 h1:wOPbBaydbdxzi1gTafDftCI/Z7vnsXw0QDPCuhiMG0g=
github.com/rbcervilla/redisstore/v9 v9.0.0/go.mod h1:q/acLpoKkTZzIsBYt0R4THDnf8W/BH6GjQYvxDSSfdI=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho=
+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
+3 -2
View File
@@ -75,8 +75,9 @@ type AuthConfig struct {
OpenID *OpenIDConfig
APIKey bool
SessionKeysFile string
SessionHashKey []byte `json:"-"`
SessionEncryptKey []byte `json:"-"`
SessionHashKey []byte `json:"-"`
SessionEncryptKey []byte `json:"-"`
SessionDriver map[string]any `mapstructure:",omitempty"`
}
type BearerConfig struct {
+8 -8
View File
@@ -81,7 +81,7 @@ func ParseRedisUniversalOptions(redisConfig map[string]interface{}, //nolint: go
opts.Addrs = val
}
if val, ok := getString(redisConfig, "client_name", false, log); ok {
if val, ok := GetString(redisConfig, "client_name", false, log); ok {
opts.ClientName = val
}
@@ -93,19 +93,19 @@ func ParseRedisUniversalOptions(redisConfig map[string]interface{}, //nolint: go
opts.Protocol = val
}
if val, ok := getString(redisConfig, "username", false, log); ok {
if val, ok := GetString(redisConfig, "username", false, log); ok {
opts.Username = val
}
if val, ok := getString(redisConfig, "password", true, log); ok {
if val, ok := GetString(redisConfig, "password", true, log); ok {
opts.Password = val
}
if val, ok := getString(redisConfig, "sentinel_username", false, log); ok {
if val, ok := GetString(redisConfig, "sentinel_username", false, log); ok {
opts.SentinelUsername = val
}
if val, ok := getString(redisConfig, "sentinel_password", true, log); ok {
if val, ok := GetString(redisConfig, "sentinel_password", true, log); ok {
opts.SentinelPassword = val
}
@@ -185,7 +185,7 @@ func ParseRedisUniversalOptions(redisConfig map[string]interface{}, //nolint: go
opts.RouteRandomly = val
}
if val, ok := getString(redisConfig, "master_name", false, log); ok {
if val, ok := GetString(redisConfig, "master_name", false, log); ok {
opts.MasterName = val
}
@@ -193,7 +193,7 @@ func ParseRedisUniversalOptions(redisConfig map[string]interface{}, //nolint: go
opts.DisableIdentity = val
}
if val, ok := getString(redisConfig, "identity_suffix", false, log); ok {
if val, ok := GetString(redisConfig, "identity_suffix", false, log); ok {
opts.IdentitySuffix = val
}
@@ -246,7 +246,7 @@ func getInt(dict map[string]interface{}, key string, log log.Logger) (int, bool)
return ret, true
}
func getString(dict map[string]interface{}, key string, hideValue bool, log log.Logger) (string, bool) {
func GetString(dict map[string]interface{}, key string, hideValue bool, log log.Logger) (string, bool) {
value, ok := dict[key]
if !ok {
return "", false
+1 -2
View File
@@ -324,8 +324,7 @@ func (c *Controller) initCookieStore() error {
c.Config.HTTP.Auth.SessionHashKey = securecookie.GenerateRandomKey(64) //nolint: gomnd
}
cookieStore, err := NewCookieStore(c.StoreController, c.Config.HTTP.Auth.SessionHashKey,
c.Config.HTTP.Auth.SessionEncryptKey)
cookieStore, err := NewCookieStore(c.Config.HTTP.Auth, c.StoreController, c.Log)
if err != nil {
return err
}
+411
View File
@@ -4952,6 +4952,417 @@ func TestOpenIDMiddleware(t *testing.T) {
}
}
func TestOpenIDMiddlewareWithRedisSessionDriver(t *testing.T) {
port := test.GetFreePort()
baseURL := test.GetBaseURL(port)
defaultVal := true
conf := config.New()
conf.HTTP.Port = port
testCases := []struct {
testCaseName string
address string
externalURL string
}{
{
address: "0.0.0.0",
externalURL: "http://" + net.JoinHostPort(conf.HTTP.Address, conf.HTTP.Port),
testCaseName: "with ExternalURL provided in config",
},
{
address: "127.0.0.1",
externalURL: "",
testCaseName: "without ExternalURL provided in config",
},
{
address: "127.0.0.1",
externalURL: "",
testCaseName: "without ExternalURL provided in config and session keys for cookies",
},
}
// need a username different than ldap one, to test both logic
htpasswdUsername, seedUser := test.GenerateRandomString()
htpasswdPassword, seedPass := test.GenerateRandomString()
htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(htpasswdUsername, htpasswdPassword))
defer os.Remove(htpasswdPath)
ldapServer := newTestLDAPServer()
port = test.GetFreePort()
ldapPort, err := strconv.Atoi(port)
if err != nil {
panic(err)
}
ldapServer.Start(ldapPort)
defer ldapServer.Stop()
mockOIDCServer, err := authutils.MockOIDCRun()
if err != nil {
panic(err)
}
defer func() {
err := mockOIDCServer.Shutdown()
if err != nil {
panic(err)
}
}()
mockOIDCConfig := mockOIDCServer.Config()
conf.HTTP.Auth = &config.AuthConfig{
HTPasswd: config.AuthHTPasswd{
Path: htpasswdPath,
},
LDAP: (&config.LDAPConfig{
Insecure: true,
Address: LDAPAddress,
Port: ldapPort,
BaseDN: LDAPBaseDN,
UserAttribute: "uid",
}).SetBindDN(LDAPBindDN).SetBindPassword(LDAPBindPassword),
OpenID: &config.OpenIDConfig{
Providers: map[string]config.OpenIDProviderConfig{
"oidc": {
ClientID: mockOIDCConfig.ClientID,
ClientSecret: mockOIDCConfig.ClientSecret,
KeyPath: "",
Issuer: mockOIDCConfig.Issuer,
Scopes: []string{"openid", "email"},
},
// just for the constructor coverage
"github": {
ClientID: mockOIDCConfig.ClientID,
ClientSecret: mockOIDCConfig.ClientSecret,
KeyPath: "",
Issuer: mockOIDCConfig.Issuer,
Scopes: []string{"openid", "email"},
},
},
},
}
searchConfig := &extconf.SearchConfig{
BaseConfig: extconf.BaseConfig{Enable: &defaultVal},
}
// UI is enabled because we also want to test access on the mgmt route
uiConfig := &extconf.UIConfig{
BaseConfig: extconf.BaseConfig{Enable: &defaultVal},
}
conf.Extensions = &extconf.ExtensionConfig{
Search: searchConfig,
UI: uiConfig,
}
ctlr := api.NewController(conf)
ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password")
for _, testcase := range testCases {
t.Run(testcase.testCaseName, func(t *testing.T) {
Convey("make controller", t, func() {
dir := t.TempDir()
ctlr.Config.Storage.RootDirectory = dir
ctlr.Config.HTTP.ExternalURL = testcase.externalURL
ctlr.Config.HTTP.Address = testcase.address
// Setup redis test server and config to use remote session store
testRedis := miniredis.RunT(t)
conf.HTTP.Auth.SessionDriver = map[string]any{
"name": "redis",
"url": "redis://" + testRedis.Addr(),
}
cm := test.NewControllerManager(ctlr)
cm.StartServer()
defer cm.StopServer()
test.WaitTillServerReady(baseURL)
Convey("browser client requests", func() {
Convey("login with no provider supplied", func() {
client := resty.New()
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
// first login user
resp, err := client.R().
SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue).
SetQueryParam("provider", "unknown").
Get(baseURL + constants.LoginPath)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
})
//nolint: dupl
Convey("make sure sessions are not used without UI header value", func() {
So(len(testRedis.Keys()), ShouldEqual, 0)
client := resty.New()
// without header should not create session
resp, err := client.R().SetBasicAuth(htpasswdUsername, htpasswdPassword).Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
So(len(testRedis.Keys()), ShouldEqual, 0)
client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue)
resp, err = client.R().SetBasicAuth(htpasswdUsername, htpasswdPassword).Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
So(len(testRedis.Keys()), ShouldEqual, 1)
// set cookies
client.SetCookies(resp.Cookies())
// should get same cookie
resp, err = client.R().SetBasicAuth(htpasswdUsername, htpasswdPassword).Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
So(len(testRedis.Keys()), ShouldEqual, 1)
resp, err = client.R().SetBasicAuth(htpasswdUsername, htpasswdPassword).
Get(baseURL + constants.FullMgmt)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
client.SetCookies(resp.Cookies())
// call endpoint with session, without credentials, (added to client after previous request)
resp, err = client.R().
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().SetBasicAuth(htpasswdUsername, htpasswdPassword).Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
So(len(testRedis.Keys()), ShouldEqual, 1)
})
Convey("login with openid and get catalog with session", func() {
client := resty.New()
client.SetRedirectPolicy(test.CustomRedirectPolicy(20))
client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue)
Convey("with callback_ui value provided", func() {
// first login user
resp, err := client.R().
SetQueryParam("provider", "oidc").
SetQueryParam("callback_ui", baseURL+"/v2/").
Get(baseURL + constants.LoginPath)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
})
// first login user
resp, err := client.R().
SetQueryParam("provider", "oidc").
Get(baseURL + constants.LoginPath)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
client.SetCookies(resp.Cookies())
// call endpoint with session (added to client after previous request)
resp, err = client.R().
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// logout with options method for coverage
resp, err = client.R().
Options(baseURL + constants.LogoutPath)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
// logout user
resp, err = client.R().
Post(baseURL + constants.LogoutPath)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// calling endpoint should fail with unauthorized access
resp, err = client.R().
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
})
//nolint: dupl
Convey("login with basic auth(htpasswd) and get catalog with session", func() {
client := resty.New()
client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue)
// without creds, should get access error
resp, err := client.R().Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
var e apiErr.Error
err = json.Unmarshal(resp.Body(), &e)
So(err, ShouldBeNil)
// first login user
// with creds, should get expected status code
resp, err = client.R().SetBasicAuth(htpasswdUsername, htpasswdPassword).Get(baseURL)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().SetBasicAuth(htpasswdUsername, htpasswdPassword).Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().SetBasicAuth(htpasswdUsername, htpasswdPassword).
Get(baseURL + constants.FullMgmt)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
client.SetCookies(resp.Cookies())
// call endpoint with session, without credentials, (added to client after previous request)
resp, err = client.R().
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().
Get(baseURL + constants.FullMgmt)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// logout user
resp, err = client.R().
Post(baseURL + constants.LogoutPath)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// calling endpoint should fail with unauthorized access
resp, err = client.R().
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
})
//nolint: dupl
Convey("login with ldap and get catalog", func() {
client := resty.New()
client.SetHeader(constants.SessionClientHeaderName, constants.SessionClientHeaderValue)
// without creds, should get access error
resp, err := client.R().Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
var e apiErr.Error
err = json.Unmarshal(resp.Body(), &e)
So(err, ShouldBeNil)
// first login user
// with creds, should get expected status code
resp, err = client.R().SetBasicAuth(username, password).Get(baseURL)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().SetBasicAuth(username, password).Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().SetBasicAuth(username, password).
Get(baseURL + constants.FullMgmt)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
client.SetCookies(resp.Cookies())
// call endpoint with session, without credentials, (added to client after previous request)
resp, err = client.R().
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
resp, err = client.R().
Get(baseURL + constants.FullMgmt)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// logout user
resp, err = client.R().
Post(baseURL + constants.LogoutPath)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// calling endpoint should fail with unauthorized access
resp, err = client.R().
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
})
Convey("unauthenticated catalog request", func() {
client := resty.New()
// mgmt should work both unauthenticated and authenticated
resp, err := client.R().
Get(baseURL + constants.FullMgmt)
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// call endpoint without session
resp, err = client.R().
Get(baseURL + "/v2/_catalog")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
})
})
})
})
}
}
func TestIsOpenIDEnabled(t *testing.T) {
Convey("make oidc server", t, func() {
port := test.GetFreePort()
+111 -7
View File
@@ -12,7 +12,12 @@ import (
"time"
"github.com/gorilla/sessions"
"github.com/rbcervilla/redisstore/v9"
"zotregistry.dev/zot/errors"
"zotregistry.dev/zot/pkg/api/config"
rediscfg "zotregistry.dev/zot/pkg/api/config/redis"
"zotregistry.dev/zot/pkg/log"
"zotregistry.dev/zot/pkg/scheduler"
"zotregistry.dev/zot/pkg/storage"
storageConstants "zotregistry.dev/zot/pkg/storage/constants"
@@ -36,7 +41,11 @@ func (c *CookieStore) RunSessionCleaner(sch *scheduler.Scheduler) {
}
}
func NewCookieStore(storeController storage.StoreController, hashKey, encryptKey []byte) (*CookieStore, error) {
func NewCookieStore(
authCfg *config.AuthConfig,
storeController storage.StoreController,
log log.Logger,
) (*CookieStore, error) {
// To store custom types in our cookies
// we must first register them using gob.Register
gob.Register(map[string]interface{}{})
@@ -47,10 +56,109 @@ func NewCookieStore(storeController storage.StoreController, hashKey, encryptKey
var needsCleanup bool
if authCfg.SessionDriver == nil {
// If the session driver is not configured, then
// behave in the usual way for file system cookie store and memory cookie store.
createdStore, returnedSessionsDir, doesStoreNeedCleanup, err := localSessionStoreInit(
storeController,
authCfg.SessionHashKey,
authCfg.SessionEncryptKey,
)
if err != nil {
return &CookieStore{}, err
}
store = createdStore
sessionsDir = returnedSessionsDir
needsCleanup = doesStoreNeedCleanup
} else {
switch authCfg.SessionDriver["name"] {
case storageConstants.RedisDriverName:
{
prefix, ok := rediscfg.GetString(authCfg.SessionDriver, "keyprefix", false, log)
if !ok {
prefix = "zotsession"
}
// The redisstore library code uses a colon to separate the prefix
// and the actual key and is expected to be part of the prefix argument.
// ref: https://github.com/rbcervilla/redisstore/blob/v9.0.0/redisstore.go#L44
// This adds a colon to the prefix only if it is not empty.
if prefix != "" {
prefix += ":"
}
client, err := rediscfg.GetRedisClient(authCfg.SessionDriver, log)
if err != nil {
return nil, err
}
redisStore, err := redisstore.NewRedisStore(context.Background(), client)
if err != nil {
return nil, err
}
redisStore.KeyPrefix(prefix)
redisStore.Options(sessions.Options{
MaxAge: cookiesMaxAge,
Path: "/",
})
store = redisStore
}
case storageConstants.LocalStorageDriverName:
{
// This behaves the same as if there was no sessionDriver config.
// It is also the same behaviour prior to supporting this config.
// This allows for a backwards compatible migration path for upgrades.
createdStore, sessDir, cleanupReq, err := localSessionStoreInit(
storeController,
authCfg.SessionHashKey,
authCfg.SessionEncryptKey,
)
if err != nil {
return &CookieStore{}, err
}
store = createdStore
sessionsDir = sessDir
needsCleanup = cleanupReq
}
default:
return nil, fmt.Errorf(
"%w: sessiondriver %s not supported",
errors.ErrBadConfig,
authCfg.SessionDriver["name"],
)
}
}
return &CookieStore{
Store: store,
rootDir: sessionsDir,
needsCleanup: needsCleanup,
}, nil
}
// Handles creation and init of a local session store.
// This can be either in memory or on the local file system.
// Returns a session Store, root directory for the sessions if applicable,
// a boolean indicating whether clean up is required, and an error.
func localSessionStoreInit(
storeController storage.StoreController,
hashKey,
encryptKey []byte,
) (sessions.Store, string, bool, error) {
var store sessions.Store
var sessionsDir string
var needsCleanup bool
if storeController.DefaultStore.Name() == storageConstants.LocalStorageDriverName {
sessionsDir = path.Join(storeController.DefaultStore.RootDir(), "_sessions")
if err := os.MkdirAll(sessionsDir, storageConstants.DefaultDirPerms); err != nil {
return &CookieStore{}, err
return &CookieStore{}, "", false, err
}
localStore := sessions.NewFilesystemStore(sessionsDir, hashKey, encryptKey)
@@ -67,11 +175,7 @@ func NewCookieStore(storeController storage.StoreController, hashKey, encryptKey
store = memStore
}
return &CookieStore{
Store: store,
rootDir: sessionsDir,
needsCleanup: needsCleanup,
}, nil
return store, sessionsDir, needsCleanup, nil
}
func IsExpiredSession(dirEntry fs.DirEntry) bool {
+56
View File
@@ -332,6 +332,58 @@ func validateCacheConfig(cfg *config.Config, logger zlog.Logger) error {
return nil
}
func validateRemoteSessionStoreConfig(cfg *config.Config, logger zlog.Logger) error {
// it is okay for the session driver config to be nil
// this is backwards compatible for older configs
if cfg.HTTP.Auth.SessionDriver == nil {
return nil
}
sessionDriverName, ok := cfg.HTTP.Auth.SessionDriver["name"]
if !ok {
msg := "must provide session driver name!"
logger.Error().Err(zerr.ErrBadConfig).Msg(msg)
return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg)
}
allowedDriverNames := []string{
storageConstants.RedisDriverName,
storageConstants.LocalStorageDriverName,
}
isValidDriver := false
for _, allowedDriverName := range allowedDriverNames {
if allowedDriverName == sessionDriverName {
isValidDriver = true
break
}
}
if !isValidDriver {
msg := fmt.Sprintf("session store driver %s is not allowed!", sessionDriverName)
logger.Error().Err(zerr.ErrBadConfig).Msg(msg)
return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg)
}
// If the redis driver is being used, then session keys must not be configured
// as redis session store does not support these yet.
if sessionDriverName == storageConstants.RedisDriverName {
if cfg.HTTP.Auth.SessionKeysFile != "" {
msg := "session keys not supported when redis session driver is used!"
logger.Error().Err(zerr.ErrBadConfig).Msg(msg)
return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg)
}
}
return nil
}
func validateExtensionsConfig(cfg *config.Config, logger zlog.Logger) error {
if cfg.Extensions != nil && cfg.Extensions.Mgmt != nil {
logger.Warn().Msg("mgmt extensions configuration option has been made redundant and will be ignored.")
@@ -405,6 +457,10 @@ func validateConfiguration(config *config.Config, logger zlog.Logger) error {
return err
}
if err := validateRemoteSessionStoreConfig(config, logger); err != nil {
return err
}
if err := validateExtensionsConfig(config, logger); err != nil {
return err
}
+250
View File
@@ -11,6 +11,7 @@ import (
. "github.com/smartystreets/goconvey/convey"
zerr "zotregistry.dev/zot/errors"
"zotregistry.dev/zot/pkg/api"
"zotregistry.dev/zot/pkg/api/config"
cli "zotregistry.dev/zot/pkg/cli/server"
@@ -572,6 +573,255 @@ storage:
So(err, ShouldBeNil)
})
Convey("Test session store config", t, func(c C) {
tmpfile, err := os.CreateTemp("", "zot-test*.json")
So(err, ShouldBeNil)
defer os.Remove(tmpfile.Name())
tmpSessionKeysFile, err := os.CreateTemp("/tmp", "keys-*.json")
So(err, ShouldBeNil)
defer os.Remove(tmpSessionKeysFile.Name())
_, err = tmpSessionKeysFile.WriteString(`{
"hashKey": "my-very-secret",
"encryptKey": "another-secret"
}`,
)
So(err, ShouldBeNil)
testCases := []struct {
name string
config []byte
isValid bool
errMsg string
}{
{
"Should fail verify if session driver is enabled, but invalid driver provided",
[]byte(`{
"storage":{
"rootDirectory":"/tmp/zot"
},
"http":{
"address":"127.0.0.1",
"port":"8080",
"realm":"zot",
"auth":{
"htpasswd":{
"path":"test/data/htpasswd"
},
"failDelay":1,
"sessionDriver":{
"name": "badDriver"
}
}
},
"extensions":{
"search": {
"cve": {
"updateInterval": "2h"
}
},
"ui": {
"enable": true
}
}
}`),
false,
zerr.ErrBadConfig.Error() +
": session store driver badDriver is not allowed!",
},
{
"Should fail verify if session driver is enabled, but driver name is not provided",
[]byte(`{
"storage":{
"rootDirectory":"/tmp/zot"
},
"http":{
"address":"127.0.0.1",
"port":"8080",
"realm":"zot",
"auth":{
"htpasswd":{
"path":"test/data/htpasswd"
},
"failDelay":1,
"sessionDriver":{
"url": "redis://localhost"
}
}
},
"extensions":{
"search": {
"cve": {
"updateInterval": "2h"
}
},
"ui": {
"enable": true
}
}
}`),
false,
zerr.ErrBadConfig.Error() + ": must provide session driver name!",
},
{
"Should fail verify if session driver is enabled and sessionKeysFile present",
[]byte(fmt.Sprintf(`{
"storage":{
"rootDirectory":"/tmp/zot"
},
"http":{
"address":"127.0.0.1",
"port":"8080",
"realm":"zot",
"auth":{
"htpasswd":{
"path":"test/data/htpasswd"
},
"failDelay":1,
"sessionKeysFile": "%s",
"sessionDriver":{
"name": "redis",
"url": "redis://localhost"
}
}
},
"extensions":{
"search": {
"cve": {
"updateInterval": "2h"
}
},
"ui": {
"enable": true
}
}
}`, tmpSessionKeysFile.Name())),
false,
zerr.ErrBadConfig.Error() + ": session keys not supported when redis session driver is used!",
},
{
"Should be successful if session driver config is valid for redis",
[]byte(`{
"storage":{
"rootDirectory":"/tmp/zot"
},
"http":{
"address":"127.0.0.1",
"port":"8080",
"realm":"zot",
"auth":{
"htpasswd":{
"path":"test/data/htpasswd"
},
"failDelay":1,
"sessionDriver":{
"name": "redis",
"url": "redis://localhost"
}
}
},
"extensions":{
"search": {
"cve": {
"updateInterval": "2h"
}
},
"ui": {
"enable": true
}
}
}`),
true,
"",
},
{
"Should be successful if session driver config is valid for local",
[]byte(`{
"storage":{
"rootDirectory":"/tmp/zot"
},
"http":{
"address":"127.0.0.1",
"port":"8080",
"realm":"zot",
"auth":{
"htpasswd":{
"path":"test/data/htpasswd"
},
"failDelay":1,
"sessionDriver":{
"name": "local"
}
}
},
"extensions":{
"search": {
"cve": {
"updateInterval": "2h"
}
},
"ui": {
"enable": true
}
}
}`),
true,
"",
},
{
"Should be successful if session driver config is missing",
[]byte(`{
"storage":{
"rootDirectory":"/tmp/zot"
},
"http":{
"address":"127.0.0.1",
"port":"8080",
"realm":"zot",
"auth":{
"htpasswd":{
"path":"test/data/htpasswd"
},
"failDelay":1
}
},
"extensions":{
"search": {
"cve": {
"updateInterval": "2h"
}
},
"ui": {
"enable": true
}
}
}`),
true,
"",
},
}
for _, testCase := range testCases {
Convey(testCase.name, func() {
err = os.WriteFile(tmpfile.Name(), testCase.config, 0o0600)
So(err, ShouldBeNil)
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
err = cli.NewServerRootCmd().Execute()
if testCase.isValid {
So(err, ShouldBeNil)
} else {
So(err, ShouldNotBeNil)
So(err.Error(), ShouldEqual, testCase.errMsg)
}
})
}
})
Convey("Test verify with bad gc retention repo patterns", t, func(c C) {
tmpfile, err := os.CreateTemp("", "zot-test*.json")
So(err, ShouldBeNil)
+1 -1
View File
@@ -9,7 +9,7 @@ PATH=$PATH:${SCRIPTPATH}/../../hack/tools/bin
tests=("pushpull" "pushpull_authn" "delete_images" "referrers" "metadata" "anonymous_policy"
"annotations" "detect_manifest_collision" "cve" "sync" "sync_docker" "sync_replica_cluster"
"scrub" "garbage_collect" "metrics" "metrics_minimal" "multiarch_index" "docker_compat" "redis_local"
"scrub" "garbage_collect" "metrics" "metrics_minimal" "multiarch_index" "docker_compat" "redis_local" "redis_session_store"
"events_nats" "events_http" "events_nats_lint_failure" "events_http_lint_failure" "events_sink_failure" "events_config_decoding")
for test in ${tests[*]}; do
+253
View File
@@ -0,0 +1,253 @@
# Note: Intended to be run as "make run-blackbox-tests" or "make run-blackbox-ci"
# Makefile target installs & checks all necessary tooling
# Extra tools that are not covered in Makefile target needs to be added in verify_prerequisites()
load helpers_zot
load helpers_redis
load ../port_helper
function verify_prerequisites() {
if [ ! $(command -v curl) ]; then
echo "you need to install curl as a prerequisite to running the tests" >&3
return 1
fi
if [ ! $(command -v docker) ]; then
echo "you need to install docker as a prerequisite to running the tests" >&3
return 1
fi
if [ ! $(command -v valkey-cli) ]; then
echo "you need to install valkey-cli as a prerequisite to running the tests" >&3
return 1
fi
return 0
}
HTPASSWD_PATH=/tmp/zotpasswd
CURL_COOKIES_DIR=/tmp/zotcookies
REDIS_TEST_CONTAINER_NAME="redis_sessions_server_local"
function setup_file() {
# Verify prerequisites are available
if ! $(verify_prerequisites); then
exit 1
fi
mkdir -p ${CURL_COOKIES_DIR}
# Create htpasswd file for basic auth
htpasswd -bBn test test123 > ${HTPASSWD_PATH}
# Setup redis server
redis_port=$(get_free_port_for_service "redis")
redis_start ${REDIS_TEST_CONTAINER_NAME} ${redis_port}
# Setup zot server
local zot_root_dir=${BATS_FILE_TMPDIR}/zot
local zot_redis_session_config_file=${BATS_FILE_TMPDIR}/zot_redis_session_config.json
zot_port=$(get_free_port_for_service "zot")
echo ${zot_port} > ${BATS_FILE_TMPDIR}/zot.port
echo ${redis_port} > ${BATS_FILE_TMPDIR}/redis.port
mkdir -p ${zot_root_dir}
cat >${zot_redis_session_config_file} <<EOF
{
"distSpecVersion": "1.1.1",
"storage": {
"rootDirectory": "${zot_root_dir}"
},
"http": {
"address": "127.0.0.1",
"port": "${zot_port}",
"auth": {
"htpasswd": {
"path": "${HTPASSWD_PATH}"
},
"sessionDriver": {
"name": "redis",
"url": "redis://localhost:${redis_port}",
"keyprefix": "zotsession"
}
}
},
"log": {
"level": "debug",
"output": "/tmp/blackbox.log"
},
"extensions": {
"ui": {
"enable": true
},
"search": {
"enable": true
}
}
}
EOF
zot_serve ${ZOT_PATH} ${zot_redis_session_config_file}
wait_zot_reachable ${zot_port}
}
function get_zot_port() {
cat ${BATS_FILE_TMPDIR}/zot.port
}
function get_redis_port() {
cat ${BATS_FILE_TMPDIR}/redis.port
}
function get_session_count() {
port=$(get_redis_port)
valkey-cli -u "redis://localhost:${port}" --scan --pattern 'zotsession:*' | wc -l
}
function perform_login() {
zot_port=$(get_zot_port)
user_num=$1
# The authorization header carries a base 64 encode of test:test123
curl -s -o /dev/null -w '%{http_code}' --cookie-jar "${CURL_COOKIES_DIR}/zot-cookie-${user_num}" \
"http://localhost:${zot_port}/v2/" \
-H 'Accept: application/json, text/plain, */*' \
-H 'Accept-Language: en-US,en;q=0.5' \
-H 'Accept-Encoding: gzip, deflate, br, zstd' \
-H 'Authorization: Basic dGVzdDp0ZXN0MTIz' \
-H 'X-ZOT-API-CLIENT: zot-ui' \
-H 'Connection: keep-alive' \
-H "Referer: http://localhost:${zot_port}/login" \
-H 'Sec-Fetch-Dest: empty' \
-H 'Sec-Fetch-Mode: cors' \
-H 'Sec-Fetch-Site: same-origin' \
-H 'Priority: u=0' \
-H 'Pragma: no-cache' \
-H 'Cache-Control: no-cache'
}
function perform_logout() {
zot_port=$(get_zot_port)
user_num=$1
curl -s -o /dev/null -w '%{http_code}' --cookie "${CURL_COOKIES_DIR}/zot-cookie-${user_num}" \
-X POST \
"http://localhost:${zot_port}/zot/auth/logout" \
-H 'Accept: application/json, text/plain, */*' \
-H 'Accept-Language: en-US,en;q=0.5' \
-H 'Accept-Encoding: gzip, deflate, br, zstd' \
-H "Origin: http://localhost:${zot_port}/login" \
-H 'X-ZOT-API-CLIENT: zot-ui' \
-H 'Connection: keep-alive' \
-H "Referer: http://localhost:${zot_port}/home" \
-H 'Sec-Fetch-Dest: empty' \
-H 'Sec-Fetch-Mode: cors' \
-H 'Sec-Fetch-Site: same-origin' \
-H 'Priority: u=0' \
-H 'Pragma: no-cache' \
-H 'Cache-Control: no-cache' \
-H 'Content-Length: 0'
}
function perform_authenticated_globalsearch() {
zot_port=$(get_zot_port)
user_num=$1
url="http://localhost:${zot_port}"
url+='/v2/_zot/ext/search?query={GlobalSearch(query:%22%22,%20requestedPage:%20{limit:3%20offset:0%20sortBy:%20DOWNLOADS}%20)%20{Page%20{TotalCount%20ItemCount}%20Repos%20{Name%20LastUpdated%20Size%20Platforms%20{%20Os%20Arch%20}%20IsStarred%20IsBookmarked%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20IsSigned%20SignatureInfo%20{%20Tool%20IsTrusted%20Author%20}%20Licenses%20Vendor%20Labels%20}%20StarCount%20DownloadCount}}}'
curl -g -s -o /dev/null -w '%{http_code}' --cookie "${CURL_COOKIES_DIR}/zot-cookie-${user_num}" \
"${url}" \
-H 'Accept: application/json' \
-H 'Accept-Language: en-US,en;q=0.5' \
-H 'Accept-Encoding: gzip, deflate, br, zstd' \
-H 'X-ZOT-API-CLIENT: zot-ui' \
-H 'Connection: keep-alive' \
-H "Referer: http://localhost:${zot_port}/home" \
-H 'Sec-Fetch-Dest: empty' \
-H 'Sec-Fetch-Mode: cors' \
-H 'Sec-Fetch-Site: same-origin' \
-H 'Pragma: no-cache' \
-H 'Cache-Control: no-cache'
}
@test "verify bulk user authentication cycle" {
num_users=20
# Note: queries are forked and run concurrently for load
for i in $(seq 1 ${num_users}); do
(
# User tries to access the global search URL without login
echo "user $i unauthenticated URL check"
status=$(perform_authenticated_globalsearch $i)
[ 401 -eq "${status}" ]
# User login
echo "user $i login"
status=$(perform_login $i)
[ 200 -eq "${status}" ]
) &
done
# wait for background processes to complete
sleep 0.1
echo "waiting for background process completion"
wait $(jobs -p)
for i in $(seq 1 ${num_users}); do
# Retry authenticated global search URL
(
echo "user $i authenticated URL check"
status=$(perform_authenticated_globalsearch $i)
[ 200 -eq "${status}" ]
) &
done
# wait for background processes to complete
sleep 0.1
echo "waiting for background process completion"
wait $(jobs -p)
cookies_count=$(get_session_count)
echo "total cookies ${cookies_count}"
[ "${cookies_count}" -eq "${num_users}" ]
for i in $(seq 1 ${num_users}); do
# All users logout
(
status=$(perform_logout $i)
[ 200 -eq "${status}" ]
) &
done
# wait for background processes to complete
sleep 0.1
echo "waiting for background process completion"
wait $(jobs -p)
for i in $(seq 1 ${num_users}); do
# All users verify no access to URL
(
status=$(perform_authenticated_globalsearch $i)
[ 401 -eq "${status}" ]
) &
done
# wait for background processes to complete
sleep 0.1
echo "waiting for background process completion"
wait $(jobs -p)
cookies_count=$(get_session_count)
echo "total cookies ${cookies_count}"
[ 0 -eq "${cookies_count}" ]
}
function teardown_file() {
zot_stop_all
redis_stop ${REDIS_TEST_CONTAINER_NAME}
rm ${HTPASSWD_PATH}
rm -r ${CURL_COOKIES_DIR}
}
+10
View File
@@ -392,5 +392,15 @@
"begin": 8700,
"end": 8750
}
},
"blackbox/redis_session_store.bats": {
"redis": {
"begin": 11400,
"end": 11409
},
"zot": {
"begin": 11420,
"end": 11429
}
}
}