0
Fork 0
mirror of https://github.com/project-zot/zot.git synced 2024-12-16 21:56:37 -05:00

fix(authn): make hashing/encryption keys used to secure cookies (#2536)

fix(authn): configurable hashing/encryption keys used to secure cookies

If they are not configured zot will generate a random hashing key at startup,
invalidating all cookies if zot is restarted. closes: #2526

Signed-off-by: Petu Eusebiu <peusebiu@cisco.com>
This commit is contained in:
peusebiu 2024-08-13 01:11:53 +03:00 committed by GitHub
parent 17dbb56ea1
commit b461619682
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 219 additions and 71 deletions

View file

@ -370,6 +370,36 @@ 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.
### Securing session based login
In order to secure session cookies used in session based authentication process you need to set the path to a file containg keys used to hash and encrypt the cookies:
`sessionKeysFile`
```
"auth": {
"htpasswd": {
"path": "test/data/htpasswd"
},
"sessionKeysFile": "/home/user/keys",
"apikey": true,
}
```
```
user@host:~/zot$ cat ../keys | jq
{
"hashKey": "my-very-secret",
"encryptKey": "another-secret"
}
```
- hashKey - used to authenticate the cookie value using HMAC. It is recommended to use a key with exactly 32 or 64 bytes.
- encryptKey - this is optional, used to encrypt the cookie value. If set, the length must correspond to the block size of the encryption algorithm. For AES, used by default, valid lengths are 16, 24, or 32 bytes to select AES-128, AES-192, or AES-256.
If at least hashKey is not set zot will create a random one which on zot restarts it will invalidate all currently valid cookies and their sessions, requiring all users to login again.
#### API keys
zot allows authentication for REST API calls using your API key as an alternative to your password.

View file

@ -13,6 +13,7 @@
"htpasswd": {
"path": "test/data/htpasswd"
},
"sessionKeysFile": "examples/sessionKeys.json",
"apikey": true,
"openid": {
"providers": {

View file

@ -0,0 +1,4 @@
{
"hashKey": "3lrioGLGO2RfG9Y7HQGgWa3ayBjMLw2auMXqEWcSXjQKc9SoQ3fKTIbO+toPYa7e",
"encryptKey": "KOzt01JrDz2uC//UBC5ZikxQw4owfmI8"
}

View file

@ -22,7 +22,6 @@ import (
"github.com/google/go-github/v52/github"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
godigest "github.com/opencontainers/go-digest"
"github.com/zitadel/oidc/v3/pkg/client/rp"
@ -334,10 +333,12 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun
for provider := range ctlr.Config.HTTP.Auth.OpenID.Providers {
if config.IsOpenIDSupported(provider) {
rp := NewRelyingPartyOIDC(context.TODO(), ctlr.Config, provider, ctlr.Log)
rp := NewRelyingPartyOIDC(context.TODO(), ctlr.Config, provider, ctlr.Config.HTTP.Auth.SessionHashKey,
ctlr.Config.HTTP.Auth.SessionEncryptKey, ctlr.Log)
ctlr.RelyingParties[provider] = rp
} else if config.IsOauth2Supported(provider) {
rp := NewRelyingPartyGithub(ctlr.Config, provider, ctlr.Log)
rp := NewRelyingPartyGithub(ctlr.Config, provider, ctlr.Config.HTTP.Auth.SessionHashKey,
ctlr.Config.HTTP.Auth.SessionEncryptKey, ctlr.Log)
ctlr.RelyingParties[provider] = rp
}
}
@ -610,20 +611,25 @@ func (rh *RouteHandler) AuthURLHandler() http.HandlerFunc {
}
}
func NewRelyingPartyOIDC(ctx context.Context, config *config.Config, provider string, log log.Logger) rp.RelyingParty {
issuer, clientID, clientSecret, redirectURI, scopes, options := getRelyingPartyArgs(config, provider, log)
func NewRelyingPartyOIDC(ctx context.Context, config *config.Config, provider string,
hashKey, encryptKey []byte, log log.Logger,
) rp.RelyingParty {
issuer, clientID, clientSecret, redirectURI, scopes, options := getRelyingPartyArgs(config,
provider, hashKey, encryptKey, log)
relyingParty, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopes, options...)
if err != nil {
log.Panic().Err(err).Str("issuer", issuer).Str("redirectURI", redirectURI).Strs("scopes", scopes).
Msg("failed to get new relying party oicd")
Msg("failed to initialize new relying party oidc")
}
return relyingParty
}
func NewRelyingPartyGithub(config *config.Config, provider string, log log.Logger) rp.RelyingParty {
_, clientID, clientSecret, redirectURI, scopes, options := getRelyingPartyArgs(config, provider, log)
func NewRelyingPartyGithub(config *config.Config, provider string, hashKey, encryptKey []byte, log log.Logger,
) rp.RelyingParty {
_, clientID, clientSecret, redirectURI, scopes,
options := getRelyingPartyArgs(config, provider, hashKey, encryptKey, log)
rpConfig := &oauth2.Config{
ClientID: clientID,
@ -636,13 +642,13 @@ func NewRelyingPartyGithub(config *config.Config, provider string, log log.Logge
relyingParty, err := rp.NewRelyingPartyOAuth(rpConfig, options...)
if err != nil {
log.Panic().Err(err).Str("redirectURI", redirectURI).Strs("scopes", scopes).
Msg("failed to get new relying party oauth")
Msg("failed to initialize new relying party oauth")
}
return relyingParty
}
func getRelyingPartyArgs(cfg *config.Config, provider string, log log.Logger) (
func getRelyingPartyArgs(cfg *config.Config, provider string, hashKey, encryptKey []byte, log log.Logger) (
string, string, string, string, []string, []rp.Option,
) {
if _, ok := cfg.HTTP.Auth.OpenID.Providers[provider]; !ok {
@ -683,9 +689,8 @@ func getRelyingPartyArgs(cfg *config.Config, provider string, log log.Logger) (
rp.WithVerifierOpts(rp.WithIssuedAtOffset(issuedAtOffset)),
}
key := securecookie.GenerateRandomKey(32) //nolint:mnd
cookieHandler := httphelper.NewCookieHandler(hashKey, encryptKey, httphelper.WithMaxAge(relyingPartyCookieMaxAge))
cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithMaxAge(relyingPartyCookieMaxAge))
options = append(options, rp.WithCookieHandler(cookieHandler))
if clientSecret == "" {
@ -839,14 +844,14 @@ func OAuth2Callback(ctlr *Controller, w http.ResponseWriter, r *http.Request, st
stateOrigin, ok := stateCookie.Values["state"].(string)
if !ok {
ctlr.Log.Error().Err(zerr.ErrInvalidStateCookie).Str("component", "openID").
Msg(": failed to get 'state' cookie from request")
Msg("failed to get 'state' cookie from request")
return "", zerr.ErrInvalidStateCookie
}
if stateOrigin != state {
ctlr.Log.Error().Err(zerr.ErrInvalidStateCookie).Str("component", "openID").
Msg(": 'state' cookie differs from the actual one")
Msg("'state' cookie differs from the actual one")
return "", zerr.ErrInvalidStateCookie
}

View file

@ -960,7 +960,7 @@ func TestCookiestoreCleanup(t *testing.T) {
DefaultStore: imgStore,
}
cookieStore, err := api.NewCookieStore(storeController)
cookieStore, err := api.NewCookieStore(storeController, nil, nil)
So(err, ShouldBeNil)
cookieStore.RunSessionCleaner(taskScheduler)
@ -995,7 +995,7 @@ func TestCookiestoreCleanup(t *testing.T) {
DefaultStore: imgStore,
}
cookieStore, err := api.NewCookieStore(storeController)
cookieStore, err := api.NewCookieStore(storeController, []byte("secret"), nil)
So(err, ShouldBeNil)
err = os.Chmod(rootDir, 0o000)

View file

@ -67,12 +67,15 @@ type AuthHTPasswd struct {
}
type AuthConfig struct {
FailDelay int
HTPasswd AuthHTPasswd
LDAP *LDAPConfig
Bearer *BearerConfig
OpenID *OpenIDConfig
APIKey bool
FailDelay int
HTPasswd AuthHTPasswd
LDAP *LDAPConfig
Bearer *BearerConfig
OpenID *OpenIDConfig
APIKey bool
SessionKeysFile string
SessionHashKey []byte `json:"-"`
SessionEncryptKey []byte `json:"-"`
}
type BearerConfig struct {
@ -81,6 +84,11 @@ type BearerConfig struct {
Cert string
}
type SessionKeys struct {
HashKey string
EncryptKey string `mapstructure:",omitempty"`
}
type OpenIDConfig struct {
Providers map[string]OpenIDProviderConfig
}

View file

@ -15,6 +15,7 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"zotregistry.dev/zot/errors"
@ -308,7 +309,14 @@ func (c *Controller) InitImageStore() error {
func (c *Controller) initCookieStore() error {
// setup sessions cookie store used to preserve logged in user in web sessions
if c.Config.IsBasicAuthnEnabled() {
cookieStore, err := NewCookieStore(c.StoreController)
if c.Config.HTTP.Auth.SessionHashKey == nil {
c.Log.Warn().Msg("hashKey is not set in config, generating a random one")
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)
if err != nil {
return err
}

View file

@ -2272,7 +2272,7 @@ func TestAuthnErrors(t *testing.T) {
}
So(func() {
api.NewRelyingPartyGithub(conf, "prov", log.NewLogger("debug", ""))
api.NewRelyingPartyGithub(conf, "prov", nil, nil, log.NewLogger("debug", ""))
}, ShouldPanic)
err = os.Chmod(tmpFile, 0o644)
@ -4099,7 +4099,7 @@ func TestNewRelyingPartyOIDC(t *testing.T) {
}
Convey("provider not found in config", func() {
So(func() { _ = api.NewRelyingPartyOIDC(ctx, conf, "notDex", log.NewLogger("debug", "")) }, ShouldPanic)
So(func() { _ = api.NewRelyingPartyOIDC(ctx, conf, "notDex", nil, nil, log.NewLogger("debug", "")) }, ShouldPanic)
})
Convey("key path not found on disk", func() {
@ -4107,7 +4107,7 @@ func TestNewRelyingPartyOIDC(t *testing.T) {
oidcProviderCfg.KeyPath = "path/to/file"
conf.HTTP.Auth.OpenID.Providers["oidc"] = oidcProviderCfg
So(func() { _ = api.NewRelyingPartyOIDC(ctx, conf, "oidc", log.NewLogger("debug", "")) }, ShouldPanic)
So(func() { _ = api.NewRelyingPartyOIDC(ctx, conf, "oidc", nil, nil, log.NewLogger("debug", "")) }, ShouldPanic)
})
Convey("https callback", func() {
@ -4116,7 +4116,7 @@ func TestNewRelyingPartyOIDC(t *testing.T) {
Key: ServerKey,
}
rp := api.NewRelyingPartyOIDC(ctx, conf, "oidc", log.NewLogger("debug", ""))
rp := api.NewRelyingPartyOIDC(ctx, conf, "oidc", nil, nil, log.NewLogger("debug", ""))
So(rp, ShouldNotBeNil)
})
@ -4125,7 +4125,7 @@ func TestNewRelyingPartyOIDC(t *testing.T) {
oidcProvider.ClientSecret = ""
conf.HTTP.Auth.OpenID.Providers["oidc"] = oidcProvider
rp := api.NewRelyingPartyOIDC(ctx, conf, "oidc", log.NewLogger("debug", ""))
rp := api.NewRelyingPartyOIDC(ctx, conf, "oidc", nil, nil, log.NewLogger("debug", ""))
So(rp, ShouldNotBeNil)
})
@ -4134,7 +4134,7 @@ func TestNewRelyingPartyOIDC(t *testing.T) {
oidcProvider.Issuer = ""
conf.HTTP.Auth.OpenID.Providers["oidc"] = oidcProvider
So(func() { _ = api.NewRelyingPartyOIDC(ctx, conf, "oidc", log.NewLogger("debug", "")) }, ShouldPanic)
So(func() { _ = api.NewRelyingPartyOIDC(ctx, conf, "oidc", nil, nil, log.NewLogger("debug", "")) }, ShouldPanic)
})
})
}
@ -4148,9 +4148,10 @@ func TestOpenIDMiddleware(t *testing.T) {
conf.HTTP.Port = port
testCases := []struct {
testCaseName string
address string
externalURL string
testCaseName string
address string
externalURL string
useSessionKeys bool
}{
{
address: "0.0.0.0",
@ -4162,6 +4163,12 @@ func TestOpenIDMiddleware(t *testing.T) {
externalURL: "",
testCaseName: "without ExternalURL provided in config",
},
{
address: "127.0.0.1",
externalURL: "",
testCaseName: "without ExternalURL provided in config and session keys for cookies",
useSessionKeys: true,
},
}
// need a username different than ldap one, to test both logic
@ -4246,6 +4253,11 @@ func TestOpenIDMiddleware(t *testing.T) {
for _, testcase := range testCases {
t.Run(testcase.testCaseName, func(t *testing.T) {
if testcase.useSessionKeys {
ctlr.Config.HTTP.Auth.SessionHashKey = []byte("3lrioGLGO2RfG9Y7HQGgWa3ayBjMLw2auMXqEWcSXjQKc9SoQ3fKTIbO+toPYa7e")
ctlr.Config.HTTP.Auth.SessionEncryptKey = []byte("KOzt01JrDz2uC//UBC5ZikxQw4owfmI8")
}
Convey("make controller", t, func() {
dir := t.TempDir()

View file

@ -11,10 +11,8 @@ import (
"strings"
"time"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
zerr "zotregistry.dev/zot/errors"
"zotregistry.dev/zot/pkg/scheduler"
"zotregistry.dev/zot/pkg/storage"
storageConstants "zotregistry.dev/zot/pkg/storage/constants"
@ -38,16 +36,11 @@ func (c *CookieStore) RunSessionCleaner(sch *scheduler.Scheduler) {
}
}
func NewCookieStore(storeController storage.StoreController) (*CookieStore, error) {
func NewCookieStore(storeController storage.StoreController, hashKey, encryptKey []byte) (*CookieStore, error) {
// To store custom types in our cookies
// we must first register them using gob.Register
gob.Register(map[string]interface{}{})
hashKey, err := getHashKey()
if err != nil {
return &CookieStore{}, err
}
var store sessions.Store
var sessionsDir string
@ -60,14 +53,14 @@ func NewCookieStore(storeController storage.StoreController) (*CookieStore, erro
return &CookieStore{}, err
}
localStore := sessions.NewFilesystemStore(sessionsDir, hashKey)
localStore := sessions.NewFilesystemStore(sessionsDir, hashKey, encryptKey)
localStore.MaxAge(cookiesMaxAge)
store = localStore
needsCleanup = true
} else {
memStore := sessions.NewCookieStore(hashKey)
memStore := sessions.NewCookieStore(hashKey, encryptKey)
memStore.MaxAge(cookiesMaxAge)
@ -81,15 +74,6 @@ func NewCookieStore(storeController storage.StoreController) (*CookieStore, erro
}, nil
}
func getHashKey() ([]byte, error) {
hashKey := securecookie.GenerateRandomKey(64)
if hashKey == nil {
return nil, zerr.ErrHashKeyNotCreated
}
return hashKey, nil
}
func IsExpiredSession(dirEntry fs.DirEntry) bool {
fileInfo, err := dirEntry.Info()
if err != nil { // may have been deleted in the meantime

View file

@ -839,6 +839,12 @@ func LoadConfiguration(config *config.Config, configPath string) error {
return err
}
if err := loadSessionKeys(config); err != nil {
log.Error().Err(err).Msg("failed to read sessionKeysFile")
return err
}
// defaults
applyDefaultValues(config, viperInstance, log)
@ -853,6 +859,26 @@ func LoadConfiguration(config *config.Config, configPath string) error {
return nil
}
func loadSessionKeys(conf *config.Config) error {
if conf.HTTP.Auth != nil && conf.HTTP.Auth.SessionKeysFile != "" {
var sessionKeys config.SessionKeys
if err := readSecretFile(conf.HTTP.Auth.SessionKeysFile, &sessionKeys, false); err != nil {
return err
}
if sessionKeys.HashKey != "" {
conf.HTTP.Auth.SessionHashKey = []byte(sessionKeys.HashKey)
}
if sessionKeys.EncryptKey != "" {
conf.HTTP.Auth.SessionEncryptKey = []byte(sessionKeys.EncryptKey)
}
}
return nil
}
func updateLDAPConfig(conf *config.Config) error {
if conf.HTTP.Auth == nil || conf.HTTP.Auth.LDAP == nil {
return nil
@ -864,8 +890,9 @@ func updateLDAPConfig(conf *config.Config) error {
return nil
}
newLDAPCredentials, err := readLDAPCredentials(conf.HTTP.Auth.LDAP.CredentialsFile)
if err != nil {
var newLDAPCredentials config.LDAPCredentials
if err := readSecretFile(conf.HTTP.Auth.LDAP.CredentialsFile, &newLDAPCredentials, true); err != nil {
return err
}
@ -875,48 +902,46 @@ func updateLDAPConfig(conf *config.Config) error {
return nil
}
func readLDAPCredentials(ldapConfigPath string) (config.LDAPCredentials, error) {
func readSecretFile(path string, v any, checkUnsetFields bool) error { //nolint: varnamelen
viperInstance := viper.NewWithOptions(viper.KeyDelimiter("::"))
viperInstance.SetConfigFile(ldapConfigPath)
viperInstance.SetConfigFile(path)
if err := viperInstance.ReadInConfig(); err != nil {
log.Error().Err(err).Msg("failed to read configuration")
log.Error().Err(err).Str("path", path).Msg("failed to read secret file configuration")
return config.LDAPCredentials{}, errors.Join(zerr.ErrBadConfig, err)
return errors.Join(zerr.ErrBadConfig, err)
}
var ldapCredentials config.LDAPCredentials
metaData := &mapstructure.Metadata{}
if err := viperInstance.Unmarshal(&ldapCredentials, metadataConfig(metaData)); err != nil {
log.Error().Err(err).Msg("failed to unmarshal ldap credentials config")
if err := viperInstance.Unmarshal(v, metadataConfig(metaData)); err != nil {
log.Error().Err(err).Str("path", path).Msg("failed to unmarshal secret file config")
return config.LDAPCredentials{}, errors.Join(zerr.ErrBadConfig, err)
return errors.Join(zerr.ErrBadConfig, err)
}
if len(metaData.Keys) == 0 {
log.Error().Err(zerr.ErrBadConfig).
Msg("failed to load ldap credentials config due to the absence of any key:value pair")
log.Error().Err(zerr.ErrBadConfig).Str("path", path).
Msg("failed to load secret file due to the absence of any key:value pair")
return config.LDAPCredentials{}, zerr.ErrBadConfig
return zerr.ErrBadConfig
}
if len(metaData.Unused) > 0 {
log.Error().Err(zerr.ErrBadConfig).Strs("keys", metaData.Unused).
Msg("failed to load ldap credentials config due to unknown keys")
log.Error().Err(zerr.ErrBadConfig).Str("path", path).Strs("keys", metaData.Unused).
Msg("failed to load secret file due to unknown keys")
return config.LDAPCredentials{}, zerr.ErrBadConfig
return zerr.ErrBadConfig
}
if len(metaData.Unset) > 0 {
if checkUnsetFields && len(metaData.Unset) > 0 {
log.Error().Err(zerr.ErrBadConfig).Strs("keys", metaData.Unset).
Msg("failed to load ldap credentials config due to unset keys")
return config.LDAPCredentials{}, zerr.ErrBadConfig
return zerr.ErrBadConfig
}
return ldapCredentials, nil
return nil
}
func authzContainsOnlyAnonymousPolicy(cfg *config.Config) bool {

View file

@ -1350,6 +1350,77 @@ storage:
So(err, ShouldBeNil)
})
Convey("Test verify good session keys config with both keys", t, func(c C) {
tmpFile, err := os.CreateTemp("", "zot-test*.json")
So(err, ShouldBeNil)
defer os.Remove(tmpFile.Name())
tmpCredsFile, err := os.CreateTemp("", "zot-cred*.json")
So(err, ShouldBeNil)
defer os.Remove(tmpCredsFile.Name())
content := []byte(`{
"hashKey":"very-secret",
"encryptKey":"another-secret"
}`)
_, err = tmpCredsFile.Write(content)
So(err, ShouldBeNil)
err = tmpCredsFile.Close()
So(err, ShouldBeNil)
content = []byte(fmt.Sprintf(`{ "distSpecVersion": "1.1.0-dev",
"storage": { "rootDirectory": "/tmp/zot" }, "http": { "address": "127.0.0.1", "port": "8080",
"auth":{"htpasswd":{"path":"test/data/htpasswd"}, "sessionKeysFile": "%s",
"failDelay": 5 } }, "log": { "level": "debug" } }`,
tmpCredsFile.Name()),
)
_, err = tmpFile.Write(content)
So(err, ShouldBeNil)
err = tmpFile.Close()
So(err, ShouldBeNil)
os.Args = []string{"cli_test", "verify", tmpFile.Name()}
err = cli.NewServerRootCmd().Execute()
So(err, ShouldBeNil)
})
Convey("Test verify good session keys config with one key", t, func(c C) {
tmpFile, err := os.CreateTemp("", "zot-test*.json")
So(err, ShouldBeNil)
defer os.Remove(tmpFile.Name())
tmpCredsFile, err := os.CreateTemp("", "zot-cred*.json")
So(err, ShouldBeNil)
defer os.Remove(tmpCredsFile.Name())
content := []byte(`{
"hashKey":"very-secret"
}`)
_, err = tmpCredsFile.Write(content)
So(err, ShouldBeNil)
err = tmpCredsFile.Close()
So(err, ShouldBeNil)
content = []byte(fmt.Sprintf(`{ "distSpecVersion": "1.1.0-dev",
"storage": { "rootDirectory": "/tmp/zot" }, "http": { "address": "127.0.0.1", "port": "8080",
"auth":{"htpasswd":{"path":"test/data/htpasswd"}, "sessionKeysFile": "%s",
"failDelay": 5 } }, "log": { "level": "debug" } }`,
tmpCredsFile.Name()),
)
_, err = tmpFile.Write(content)
So(err, ShouldBeNil)
err = tmpFile.Close()
So(err, ShouldBeNil)
os.Args = []string{"cli_test", "verify", tmpFile.Name()}
err = cli.NewServerRootCmd().Execute()
So(err, ShouldBeNil)
})
Convey("Test verify good ldap config", t, func(c C) {
tmpFile, err := os.CreateTemp("", "zot-test*.json")
So(err, ShouldBeNil)