Merge pull request #1288 from saschagrunert/login-logout-tests

Support updating registry credentials scoped to namespaces/repos
This commit is contained in:
Daniel J Walsh 2021-07-16 13:04:44 -04:00 committed by GitHub
commit d695b98f83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 294 additions and 43 deletions

View File

@ -21,14 +21,15 @@ Except the primary (read/write) file, other files are read-only, unless the user
The auth.json file stores encrypted authentication information for the The auth.json file stores encrypted authentication information for the
user to container image registries. The file can have zero to many entries and user to container image registries. The file can have zero to many entries and
is created by a `login` command from a container tool such as `podman login`, is created by a `login` command from a container tool such as `podman login`,
`buildah login` or `skopeo login`. Each entry includes the name of the registry and then an auth `buildah login` or `skopeo login`. Each entry either contains a single
token in the form of a base64 encoded string from the concatenation of the hostname (e.g. `docker.io`) or a namespace (e.g. `quay.io/user/image`) as a key
username, a colon, and the password. The registry name can additionally contain and an auth token in the form of a base64 encoded string as value of `auth`. The
a path or repository name (an image name without tag or digest). The path (or token is built from the concatenation of the username, a colon, and the
namespace) is matched in its hierarchical order when checking for available password. The registry name can additionally contain a repository name (an image
authentications. For example, an image pull for name without tag or digest) and namespaces. The path (or namespace) is matched
`my-registry.local/namespace/user/image:latest` will result in a lookup in in its hierarchical order when checking for available authentications. For
`auth.json` in the following order: example, an image pull for `my-registry.local/namespace/user/image:latest` will
result in a lookup in `auth.json` in the following order:
- `my-registry.local/namespace/user/image` - `my-registry.local/namespace/user/image`
- `my-registry.local/namespace/user` - `my-registry.local/namespace/user`

View File

@ -54,10 +54,17 @@ var (
// SetCredentials stores the username and password in a location // SetCredentials stores the username and password in a location
// appropriate for sys and the users configuration. // appropriate for sys and the users configuration.
// A valid key can be either a registry hostname or additionally a namespace if
// the AuthenticationFileHelper is being unsed.
// Returns a human-redable description of the location that was updated. // Returns a human-redable description of the location that was updated.
// NOTE: The return value is only intended to be read by humans; its form is not an API, // NOTE: The return value is only intended to be read by humans; its form is not an API,
// it may change (or new forms can be added) any time. // it may change (or new forms can be added) any time.
func SetCredentials(sys *types.SystemContext, registry, username, password string) (string, error) { func SetCredentials(sys *types.SystemContext, key, username, password string) (string, error) {
isNamespaced, err := validateKey(key)
if err != nil {
return "", err
}
helpers, err := sysregistriesv2.CredentialHelpers(sys) helpers, err := sysregistriesv2.CredentialHelpers(sys)
if err != nil { if err != nil {
return "", err return "", err
@ -72,33 +79,45 @@ func SetCredentials(sys *types.SystemContext, registry, username, password strin
// Special-case the built-in helpers for auth files. // Special-case the built-in helpers for auth files.
case sysregistriesv2.AuthenticationFileHelper: case sysregistriesv2.AuthenticationFileHelper:
desc, err = modifyJSON(sys, func(auths *dockerConfigFile) (bool, error) { desc, err = modifyJSON(sys, func(auths *dockerConfigFile) (bool, error) {
if ch, exists := auths.CredHelpers[registry]; exists { if ch, exists := auths.CredHelpers[key]; exists {
return false, setAuthToCredHelper(ch, registry, username, password) if isNamespaced {
return false, unsupportedNamespaceErr(ch)
}
return false, setAuthToCredHelper(ch, key, username, password)
} }
creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
newCreds := dockerAuthConfig{Auth: creds} newCreds := dockerAuthConfig{Auth: creds}
auths.AuthConfigs[registry] = newCreds auths.AuthConfigs[key] = newCreds
return true, nil return true, nil
}) })
// External helpers. // External helpers.
default: default:
desc = fmt.Sprintf("credential helper: %s", helper) if isNamespaced {
err = setAuthToCredHelper(helper, registry, username, password) err = unsupportedNamespaceErr(helper)
} else {
desc = fmt.Sprintf("credential helper: %s", helper)
err = setAuthToCredHelper(helper, key, username, password)
}
} }
if err != nil { if err != nil {
multiErr = multierror.Append(multiErr, err) multiErr = multierror.Append(multiErr, err)
logrus.Debugf("Error storing credentials for %s in credential helper %s: %v", registry, helper, err) logrus.Debugf("Error storing credentials for %s in credential helper %s: %v", key, helper, err)
continue continue
} }
logrus.Debugf("Stored credentials for %s in credential helper %s", registry, helper) logrus.Debugf("Stored credentials for %s in credential helper %s", key, helper)
return desc, nil return desc, nil
} }
return "", multiErr return "", multiErr
} }
func unsupportedNamespaceErr(helper string) error {
return errors.Errorf("namespaced key is not supported for credential helper %s", helper)
}
// SetAuthentication stores the username and password in the credential helper or file // SetAuthentication stores the username and password in the credential helper or file
func SetAuthentication(sys *types.SystemContext, registry, username, password string) error { // See the documentation of SetCredentials for format of "key"
_, err := SetCredentials(sys, registry, username, password) func SetAuthentication(sys *types.SystemContext, key, username, password string) error {
_, err := SetCredentials(sys, key, username, password)
return err return err
} }
@ -326,9 +345,16 @@ func getAuthenticationWithHomeDir(sys *types.SystemContext, registry, homeDir st
return auth.Username, auth.Password, nil return auth.Username, auth.Password, nil
} }
// RemoveAuthentication removes credentials for `registry` from all possible // RemoveAuthentication removes credentials for `key` from all possible
// sources such as credential helpers and auth files. // sources such as credential helpers and auth files.
func RemoveAuthentication(sys *types.SystemContext, registry string) error { // A valid key can be either a registry hostname or additionally a namespace if
// the AuthenticationFileHelper is being unsed.
func RemoveAuthentication(sys *types.SystemContext, key string) error {
isNamespaced, err := validateKey(key)
if err != nil {
return err
}
helpers, err := sysregistriesv2.CredentialHelpers(sys) helpers, err := sysregistriesv2.CredentialHelpers(sys)
if err != nil { if err != nil {
return err return err
@ -338,17 +364,22 @@ func RemoveAuthentication(sys *types.SystemContext, registry string) error {
isLoggedIn := false isLoggedIn := false
removeFromCredHelper := func(helper string) { removeFromCredHelper := func(helper string) {
err := deleteAuthFromCredHelper(helper, registry) if isNamespaced {
if err == nil { logrus.Debugf("Not removing credentials because namespaced keys are not supported for the credential helper: %s", helper)
logrus.Debugf("Credentials for %q were deleted from credential helper %s", registry, helper)
isLoggedIn = true
return return
} else {
err := deleteAuthFromCredHelper(helper, key)
if err == nil {
logrus.Debugf("Credentials for %q were deleted from credential helper %s", key, helper)
isLoggedIn = true
return
}
if credentials.IsErrCredentialsNotFoundMessage(err.Error()) {
logrus.Debugf("Not logged in to %s with credential helper %s", key, helper)
return
}
} }
if credentials.IsErrCredentialsNotFoundMessage(err.Error()) { multiErr = multierror.Append(multiErr, errors.Wrapf(err, "removing credentials for %s from credential helper %s", key, helper))
logrus.Debugf("Not logged in to %s with credential helper %s", registry, helper)
return
}
multiErr = multierror.Append(multiErr, errors.Wrapf(err, "removing credentials for %s from credential helper %s", registry, helper))
} }
for _, helper := range helpers { for _, helper := range helpers {
@ -357,15 +388,12 @@ func RemoveAuthentication(sys *types.SystemContext, registry string) error {
// Special-case the built-in helper for auth files. // Special-case the built-in helper for auth files.
case sysregistriesv2.AuthenticationFileHelper: case sysregistriesv2.AuthenticationFileHelper:
_, err = modifyJSON(sys, func(auths *dockerConfigFile) (bool, error) { _, err = modifyJSON(sys, func(auths *dockerConfigFile) (bool, error) {
if innerHelper, exists := auths.CredHelpers[registry]; exists { if innerHelper, exists := auths.CredHelpers[key]; exists {
removeFromCredHelper(innerHelper) removeFromCredHelper(innerHelper)
} }
if _, ok := auths.AuthConfigs[registry]; ok { if _, ok := auths.AuthConfigs[key]; ok {
isLoggedIn = true isLoggedIn = true
delete(auths.AuthConfigs, registry) delete(auths.AuthConfigs, key)
} else if _, ok := auths.AuthConfigs[normalizeRegistry(registry)]; ok {
isLoggedIn = true
delete(auths.AuthConfigs, normalizeRegistry(registry))
} }
return true, multiErr return true, multiErr
}) })
@ -699,18 +727,18 @@ func decodeDockerAuth(conf dockerAuthConfig) (types.DockerAuthConfig, error) {
// to just an hostname. // to just an hostname.
// Copied from github.com/docker/docker/registry/auth.go // Copied from github.com/docker/docker/registry/auth.go
func convertToHostname(url string) string { func convertToHostname(url string) string {
stripped := url stripped := stripScheme(url)
if strings.HasPrefix(url, "http://") {
stripped = strings.TrimPrefix(url, "http://")
} else if strings.HasPrefix(url, "https://") {
stripped = strings.TrimPrefix(url, "https://")
}
nameParts := strings.SplitN(stripped, "/", 2) nameParts := strings.SplitN(stripped, "/", 2)
return nameParts[0] return nameParts[0]
} }
// stripScheme striped the http|https scheme from the provided URL.
func stripScheme(url string) string {
stripped := strings.TrimPrefix(url, "http://")
stripped = strings.TrimPrefix(stripped, "https://")
return stripped
}
func normalizeRegistry(registry string) string { func normalizeRegistry(registry string) string {
normalized := convertToHostname(registry) normalized := convertToHostname(registry)
switch normalized { switch normalized {
@ -719,3 +747,14 @@ func normalizeRegistry(registry string) string {
} }
return normalized return normalized
} }
// validateKey verifies that the input key does not have a prefix that is not
// allowed and returns an indicator if the key is namespaced.
func validateKey(key string) (isNamespaced bool, err error) {
if strings.HasPrefix(key, "http://") || strings.HasPrefix(key, "https://") {
return isNamespaced, errors.Errorf("key %s contains http[s]:// prefix", key)
}
// check if the provided key contains one or more subpaths.
return strings.ContainsRune(key, '/'), nil
}

View File

@ -1,6 +1,7 @@
package config package config
import ( import (
"encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
@ -675,3 +676,213 @@ func TestAuthKeysForRef(t *testing.T) {
require.Equal(t, tc.expected, result, tc.name) require.Equal(t, tc.expected, result, tc.name)
} }
} }
func TestSetCredentials(t *testing.T) {
const (
usernamePrefix = "username-"
passwordPrefix = "password-"
)
getAuth := func(sys *types.SystemContext, input string) types.DockerAuthConfig {
ref, err := reference.ParseNamed(input)
require.NoError(t, err)
auth, err := GetCredentialsForRef(sys, ref)
require.NoError(t, err)
return auth
}
for _, tc := range []struct {
input []string
assert func(*types.SystemContext, dockerConfigFile)
}{
{
input: []string{"quay.io"},
assert: func(sys *types.SystemContext, auth dockerConfigFile) {
assert.Len(t, auth.AuthConfigs, 1)
assert.NotEmpty(t, auth.AuthConfigs["quay.io"].Auth)
},
},
{
input: []string{"quay.io/a/b/c/d/image"},
assert: func(sys *types.SystemContext, auth dockerConfigFile) {
assert.Len(t, auth.AuthConfigs, 1)
assert.NotEmpty(t, auth.AuthConfigs["quay.io/a/b/c/d/image"].Auth)
ta := getAuth(sys, "quay.io/a/b/c/d/image")
assert.Equal(t, usernamePrefix+"0", ta.Username)
assert.Equal(t, passwordPrefix+"0", ta.Password)
},
},
{
input: []string{
"quay.io/a/b/c",
"quay.io/a/b",
"quay.io/a",
"quay.io",
"my-registry.local",
"my-registry.local",
},
assert: func(sys *types.SystemContext, auth dockerConfigFile) {
assert.Len(t, auth.AuthConfigs, 5)
assert.NotEmpty(t, auth.AuthConfigs["quay.io/a/b/c"].Auth)
assert.NotEmpty(t, auth.AuthConfigs["quay.io/a/b"].Auth)
assert.NotEmpty(t, auth.AuthConfigs["quay.io/a"].Auth)
assert.NotEmpty(t, auth.AuthConfigs["quay.io"].Auth)
assert.NotEmpty(t, auth.AuthConfigs["my-registry.local"].Auth)
ta0 := getAuth(sys, "quay.io/a/b/c")
assert.Equal(t, usernamePrefix+"0", ta0.Username)
assert.Equal(t, passwordPrefix+"0", ta0.Password)
ta1 := getAuth(sys, "quay.io/a/b")
assert.Equal(t, usernamePrefix+"1", ta1.Username)
assert.Equal(t, passwordPrefix+"1", ta1.Password)
ta2 := getAuth(sys, "quay.io/a")
assert.Equal(t, usernamePrefix+"2", ta2.Username)
assert.Equal(t, passwordPrefix+"2", ta2.Password)
},
},
} {
tmpFile, err := ioutil.TempFile("", "auth.json.set")
require.NoError(t, err)
defer os.RemoveAll(tmpFile.Name())
_, err = tmpFile.WriteString("{}")
require.NoError(t, err)
sys := &types.SystemContext{AuthFilePath: tmpFile.Name()}
for i, input := range tc.input {
_, err := SetCredentials(
sys,
input,
usernamePrefix+fmt.Sprint(i),
passwordPrefix+fmt.Sprint(i),
)
assert.NoError(t, err)
}
auth, err := readJSONFile(tmpFile.Name(), false)
require.NoError(t, err)
tc.assert(sys, auth)
}
}
func TestRemoveAuthentication(t *testing.T) {
testAuth := dockerAuthConfig{Auth: "ZXhhbXBsZTpvcmc="}
for _, tc := range []struct {
config dockerConfigFile
inputs []string
shouldError bool
assert func(dockerConfigFile)
}{
{
config: dockerConfigFile{
AuthConfigs: map[string]dockerAuthConfig{
"quay.io": testAuth,
},
},
inputs: []string{"quay.io"},
assert: func(auth dockerConfigFile) {
assert.Len(t, auth.AuthConfigs, 0)
},
},
{
config: dockerConfigFile{
AuthConfigs: map[string]dockerAuthConfig{
"quay.io": testAuth,
},
},
inputs: []string{"quay.io/user/image"},
shouldError: true, // not logged in
assert: func(auth dockerConfigFile) {
assert.Len(t, auth.AuthConfigs, 1)
assert.NotEmpty(t, auth.AuthConfigs["quay.io"].Auth)
},
},
{
config: dockerConfigFile{
AuthConfigs: map[string]dockerAuthConfig{
"quay.io": testAuth,
"my-registry.local": testAuth,
},
},
inputs: []string{"my-registry.local"},
assert: func(auth dockerConfigFile) {
assert.Len(t, auth.AuthConfigs, 1)
assert.NotEmpty(t, auth.AuthConfigs["quay.io"].Auth)
},
},
{
config: dockerConfigFile{
AuthConfigs: map[string]dockerAuthConfig{
"quay.io/a/b/c": testAuth,
"quay.io/a/b": testAuth,
"quay.io/a": testAuth,
"quay.io": testAuth,
"my-registry.local": testAuth,
},
},
inputs: []string{
"quay.io/a/b",
"quay.io",
"my-registry.local",
},
assert: func(auth dockerConfigFile) {
assert.Len(t, auth.AuthConfigs, 2)
assert.NotEmpty(t, auth.AuthConfigs["quay.io/a/b/c"].Auth)
assert.NotEmpty(t, auth.AuthConfigs["quay.io/a"].Auth)
},
},
} {
content, err := json.Marshal(&tc.config)
require.NoError(t, err)
tmpFile, err := ioutil.TempFile("", "auth.json")
require.NoError(t, err)
defer os.RemoveAll(tmpFile.Name())
_, err = tmpFile.Write(content)
require.NoError(t, err)
sys := &types.SystemContext{AuthFilePath: tmpFile.Name()}
for _, input := range tc.inputs {
err := RemoveAuthentication(sys, input)
if tc.shouldError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
}
auth, err := readJSONFile(tmpFile.Name(), false)
require.NoError(t, err)
tc.assert(auth)
}
}
func TestValidateKey(t *testing.T) {
for _, tc := range []struct {
key string
shouldError bool
isNamespaced bool
}{
{"my-registry.local", false, false},
{"https://my-registry.local", true, false},
{"my-registry.local/path", false, true},
{"quay.io/a/b/c/d", false, true},
} {
isNamespaced, err := validateKey(tc.key)
if tc.shouldError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tc.isNamespaced, isNamespaced)
}
}