mirror of
https://github.com/project-zot/zot.git
synced 2026-06-19 14:08:01 +08:00
79439bbf63
Add support for configurable identity attributes in mTLS authentication, allowing identity extraction from CommonName, Subject DN, Email SAN, URI SAN, or DNSName SAN with fallback chain support. Includes regex pattern matching for URI SANs (e.g., SPIFFE workload IDs). - Add MTLSConfig with identity attributes, URISANPattern, and index fields - Implement extractMTLSIdentity with fallback chain logic - Move the mtls tests in the api package to pkg/api/mtls_test.go Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
1861 lines
59 KiB
Go
1861 lines
59 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
. "github.com/smartystreets/goconvey/convey"
|
|
"gopkg.in/resty.v1"
|
|
|
|
"zotregistry.dev/zot/v2/pkg/api"
|
|
"zotregistry.dev/zot/v2/pkg/api/config"
|
|
test "zotregistry.dev/zot/v2/pkg/test/common"
|
|
"zotregistry.dev/zot/v2/pkg/test/mocks"
|
|
tlsutils "zotregistry.dev/zot/v2/pkg/test/tls"
|
|
)
|
|
|
|
var ErrUnexpectedError = errors.New("error: unexpected error")
|
|
|
|
// setupTestCerts generates CA, server, and client certificates for testing.
|
|
// Returns paths to certificate files and PEM data for CA cert.
|
|
func setupTestCerts(t *testing.T) (
|
|
string, string, string, string, string, []byte,
|
|
) {
|
|
t.Helper()
|
|
tempDir := t.TempDir()
|
|
|
|
// Generate CA certificate (10 years validity, matching gen_certs.sh)
|
|
caOpts := &tlsutils.CertificateOptions{
|
|
CommonName: "*",
|
|
NotAfter: time.Now().AddDate(10, 0, 0),
|
|
}
|
|
caCertPEM, caKeyPEM, err := tlsutils.GenerateCACert(caOpts)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate CA cert: %v", err)
|
|
}
|
|
|
|
caCertPath := path.Join(tempDir, "ca.crt")
|
|
caKeyPath := path.Join(tempDir, "ca.key")
|
|
err = os.WriteFile(caCertPath, caCertPEM, 0o600)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write CA cert: %v", err)
|
|
}
|
|
_ = os.WriteFile(caKeyPath, caKeyPEM, 0o600)
|
|
|
|
// Generate server certificate
|
|
serverCertPath := path.Join(tempDir, "server.cert")
|
|
serverKeyPath := path.Join(tempDir, "server.key")
|
|
serverOpts := &tlsutils.CertificateOptions{
|
|
Hostname: "127.0.0.1",
|
|
CommonName: "*",
|
|
OrganizationalUnit: "TestServer",
|
|
NotAfter: time.Now().AddDate(10, 0, 0),
|
|
}
|
|
err = tlsutils.GenerateServerCertToFile(caCertPEM, caKeyPEM, serverCertPath, serverKeyPath, serverOpts)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate server cert: %v", err)
|
|
}
|
|
|
|
// Generate client certificate (10 years validity, matching gen_certs.sh)
|
|
clientCertPath := path.Join(tempDir, "client.cert")
|
|
clientKeyPath := path.Join(tempDir, "client.key")
|
|
clientOpts := &tlsutils.CertificateOptions{
|
|
CommonName: "testclient",
|
|
OrganizationalUnit: "TestClient",
|
|
NotAfter: time.Now().AddDate(10, 0, 0),
|
|
}
|
|
err = tlsutils.GenerateClientCertToFile(caCertPEM, caKeyPEM, clientCertPath, clientKeyPath, clientOpts)
|
|
if err != nil {
|
|
t.Fatalf("Failed to generate client cert: %v", err)
|
|
}
|
|
|
|
return caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM
|
|
}
|
|
|
|
// mTLSTestCase defines a test case for mTLS identity extraction.
|
|
type mTLSTestCase struct {
|
|
name string
|
|
clientCertOptions *tlsutils.CertificateOptions
|
|
mtlsConfig *config.MTLSConfig
|
|
allowedUsers []string // Users allowed in access control
|
|
expectedIdentity string // Expected identity extracted from cert
|
|
expectedStatus int // Expected HTTP status code
|
|
description string // Test description
|
|
}
|
|
|
|
// getExpectedSubjectDN constructs the expected Subject DN string based on the certificate options.
|
|
// This matches the format used by tlsutils for client certificates.
|
|
func getExpectedSubjectDN(commonName string) string {
|
|
subject := pkix.Name{
|
|
Organization: []string{"Test Client"},
|
|
Country: []string{"US"},
|
|
Province: []string{""},
|
|
Locality: []string{"San Francisco"},
|
|
StreetAddress: []string{""},
|
|
PostalCode: []string{""},
|
|
}
|
|
if commonName != "" {
|
|
subject.CommonName = commonName
|
|
}
|
|
|
|
return subject.String()
|
|
}
|
|
|
|
// runMTLSTest executes a single mTLS test case.
|
|
func runMTLSTest(t *testing.T, testCase mTLSTestCase) {
|
|
t.Helper()
|
|
|
|
tempDir := t.TempDir()
|
|
|
|
// Generate CA certificate
|
|
caCert, caKey, err := tlsutils.GenerateCACert()
|
|
So(err, ShouldBeNil)
|
|
caCertPath := path.Join(tempDir, "ca.crt")
|
|
err = os.WriteFile(caCertPath, caCert, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate server certificate
|
|
serverCertPath := path.Join(tempDir, "server.crt")
|
|
serverKeyPath := path.Join(tempDir, "server.key")
|
|
serverOpts := &tlsutils.CertificateOptions{
|
|
Hostname: "localhost",
|
|
}
|
|
err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, serverOpts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate client certificate
|
|
clientCertPath := path.Join(tempDir, "client.crt")
|
|
clientKeyPath := path.Join(tempDir, "client.key")
|
|
err = tlsutils.GenerateClientCertToFile(caCert, caKey, clientCertPath, clientKeyPath, testCase.clientCertOptions)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Set up server
|
|
conf := config.New()
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetSecureBaseURL(port)
|
|
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
conf.HTTP.Auth = &config.AuthConfig{
|
|
MTLS: testCase.mtlsConfig,
|
|
}
|
|
|
|
// Set up access control
|
|
repoPolicies := make([]config.Policy, 0)
|
|
if len(testCase.allowedUsers) > 0 {
|
|
repoPolicies = append(repoPolicies, config.Policy{
|
|
Users: testCase.allowedUsers,
|
|
Actions: []string{"read", "create"},
|
|
})
|
|
}
|
|
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Repositories: config.Repositories{
|
|
"**": config.PolicyGroup{
|
|
AnonymousPolicy: make([]string, 0),
|
|
Policies: make([]config.Policy, 0),
|
|
},
|
|
"test-repo": config.PolicyGroup{
|
|
Policies: repoPolicies,
|
|
},
|
|
},
|
|
}
|
|
conf.Storage.RootDirectory = t.TempDir()
|
|
|
|
ctlr := api.NewController(conf)
|
|
cm := test.NewControllerManager(ctlr)
|
|
|
|
cm.StartAndWait(port)
|
|
defer cm.StopServer()
|
|
|
|
// Set up client
|
|
caCertPEM, err := os.ReadFile(caCertPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
client := resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{clientCert},
|
|
RootCAs: caCertPool,
|
|
})
|
|
|
|
// Make request
|
|
resp, err := client.R().Get(baseURL + "/v2/test-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, testCase.expectedStatus)
|
|
}
|
|
|
|
func TestExtractMTLSIdentity(t *testing.T) {
|
|
testCases := []mTLSTestCase{
|
|
// Positive tests - authentication should succeed
|
|
{
|
|
name: "CommonName",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"CommonName"},
|
|
},
|
|
allowedUsers: []string{"testuser"},
|
|
expectedIdentity: "testuser",
|
|
expectedStatus: http.StatusNotFound, // 404 means auth passed
|
|
description: "Extract identity from CommonName",
|
|
},
|
|
{
|
|
name: "Subject",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"Subject"},
|
|
},
|
|
allowedUsers: []string{getExpectedSubjectDN("testuser")},
|
|
expectedIdentity: getExpectedSubjectDN("testuser"),
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity from Subject DN",
|
|
},
|
|
{
|
|
name: "EmailSAN",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
EmailAddresses: []string{"testuser@example.com"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"Email"},
|
|
},
|
|
allowedUsers: []string{"testuser@example.com"},
|
|
expectedIdentity: "testuser@example.com",
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity from Email SAN",
|
|
},
|
|
{
|
|
name: "DNSNameSAN",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
DNSNames: []string{"client.example.com"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"DNSName"},
|
|
},
|
|
allowedUsers: []string{"client.example.com"},
|
|
expectedIdentity: "client.example.com",
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity from DNSName SAN",
|
|
},
|
|
{
|
|
name: "URISAN",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
URIs: []string{"spiffe://example.org/workload/testuser"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"URI"},
|
|
},
|
|
allowedUsers: []string{"spiffe://example.org/workload/testuser"},
|
|
expectedIdentity: "spiffe://example.org/workload/testuser",
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity from URI SAN",
|
|
},
|
|
{
|
|
name: "URISANWithRegex",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
URIs: []string{"spiffe://example.org/workload/testuser"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"URI"},
|
|
URISANPattern: "spiffe://example.org/workload/(.*)",
|
|
},
|
|
allowedUsers: []string{"testuser"},
|
|
expectedIdentity: "testuser",
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity from URI SAN with regex pattern",
|
|
},
|
|
{
|
|
name: "FallbackChain",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"Email", "DNSName", "CommonName"},
|
|
},
|
|
allowedUsers: []string{"testuser"},
|
|
expectedIdentity: "testuser",
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity using fallback chain",
|
|
},
|
|
{
|
|
name: "CaseInsensitive",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"commonname"}, // lowercase
|
|
},
|
|
allowedUsers: []string{"testuser"},
|
|
expectedIdentity: "testuser",
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity with case-insensitive source name",
|
|
},
|
|
{
|
|
name: "DNSANIndex1",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
DNSNames: []string{"first.example.com", "second.example.com"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"DNSName"},
|
|
DNSANIndex: 1, // Use second DNS name
|
|
},
|
|
allowedUsers: []string{"second.example.com"},
|
|
expectedIdentity: "second.example.com",
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity from DNS SAN with index 1",
|
|
},
|
|
{
|
|
name: "URISANIndex1",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
URIs: []string{
|
|
"spiffe://example.org/workload/first",
|
|
"spiffe://example.org/workload/second",
|
|
},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"URI"},
|
|
URISANIndex: 1, // Use second URI
|
|
},
|
|
allowedUsers: []string{"spiffe://example.org/workload/second"},
|
|
expectedIdentity: "spiffe://example.org/workload/second",
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity from URI SAN with index 1",
|
|
},
|
|
{
|
|
name: "EmailSANIndex1",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
EmailAddresses: []string{"first@example.com", "second@example.com"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"Email"},
|
|
EmailSANIndex: 1, // Use second email
|
|
},
|
|
allowedUsers: []string{"second@example.com"},
|
|
expectedIdentity: "second@example.com",
|
|
expectedStatus: http.StatusNotFound,
|
|
description: "Extract identity from Email SAN with index 1",
|
|
},
|
|
// Negative tests - authentication should fail
|
|
{
|
|
name: "CommonNameNotAllowed",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"CommonName"},
|
|
},
|
|
allowedUsers: []string{"otheruser"}, // Different user
|
|
expectedIdentity: "testuser",
|
|
expectedStatus: http.StatusForbidden, // 403 means auth passed but access denied
|
|
description: "Authentication succeeds but user not in allowed list",
|
|
},
|
|
{
|
|
name: "EmailSANNotAllowed",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
EmailAddresses: []string{"testuser@example.com"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"Email"},
|
|
},
|
|
allowedUsers: []string{"other@example.com"},
|
|
expectedIdentity: "testuser@example.com",
|
|
expectedStatus: http.StatusForbidden,
|
|
description: "Email SAN extracted but user not in allowed list",
|
|
},
|
|
{
|
|
name: "DNSNameSANNotAllowed",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
DNSNames: []string{"client.example.com"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"DNSName"},
|
|
},
|
|
allowedUsers: []string{"other.example.com"},
|
|
expectedIdentity: "client.example.com",
|
|
expectedStatus: http.StatusForbidden,
|
|
description: "DNSName SAN extracted but user not in allowed list",
|
|
},
|
|
{
|
|
name: "URISANNotAllowed",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
URIs: []string{"spiffe://example.org/workload/testuser"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"URI"},
|
|
},
|
|
allowedUsers: []string{"spiffe://example.org/workload/other"},
|
|
expectedIdentity: "spiffe://example.org/workload/testuser",
|
|
expectedStatus: http.StatusForbidden,
|
|
description: "URI SAN extracted but user not in allowed list",
|
|
},
|
|
{
|
|
name: "URISANRegexNotAllowed",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
URIs: []string{"spiffe://example.org/workload/testuser"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"URI"},
|
|
URISANPattern: "spiffe://example.org/workload/(.*)",
|
|
},
|
|
allowedUsers: []string{"otheruser"},
|
|
expectedIdentity: "testuser",
|
|
expectedStatus: http.StatusForbidden,
|
|
description: "URI SAN regex extracted but user not in allowed list",
|
|
},
|
|
{
|
|
name: "NoIdentitySource",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
// No CN, no SANs
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"CommonName"},
|
|
},
|
|
allowedUsers: []string{},
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized, // 401 means auth failed
|
|
description: "No identity found in certificate",
|
|
},
|
|
{
|
|
name: "DefaultConfigWithoutCN",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
// CommonName intentionally not set - no CN, no SANs
|
|
},
|
|
mtlsConfig: nil, // Default behavior (should default to CommonName)
|
|
allowedUsers: []string{}, // Not used - authentication fails before authorization is checked
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized, // 401 means auth failed - no CN to extract
|
|
description: "Default config (CommonName) with certificate without CN - " +
|
|
"authentication fails before authorization",
|
|
},
|
|
{
|
|
name: "FallbackChainAllFail",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
// No CN, no SANs
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"Email", "DNSName", "CommonName"},
|
|
},
|
|
allowedUsers: []string{},
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized,
|
|
description: "Fallback chain fails when no identity attributes are available",
|
|
},
|
|
{
|
|
name: "InvalidURISANIndex",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
URIs: []string{"spiffe://example.org/workload/testuser"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"URI"},
|
|
URISANIndex: 5, // Out of range
|
|
},
|
|
allowedUsers: []string{},
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized,
|
|
description: "URI SAN index out of range",
|
|
},
|
|
{
|
|
name: "InvalidDNSANIndex",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
DNSNames: []string{"client.example.com"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"DNSName"},
|
|
DNSANIndex: 5, // Out of range
|
|
},
|
|
allowedUsers: []string{},
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized,
|
|
description: "DNS SAN index out of range",
|
|
},
|
|
{
|
|
name: "InvalidEmailSANIndex",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
EmailAddresses: []string{"testuser@example.com"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"Email"},
|
|
EmailSANIndex: 5, // Out of range
|
|
},
|
|
allowedUsers: []string{},
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized,
|
|
description: "Email SAN index out of range",
|
|
},
|
|
{
|
|
name: "URISANRegexNoMatch",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
URIs: []string{"spiffe://example.org/workload/testuser"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"URI"},
|
|
URISANPattern: "spiffe://other.org/workload/(.*)", // Won't match
|
|
},
|
|
allowedUsers: []string{},
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized,
|
|
description: "URI SAN regex pattern doesn't match",
|
|
},
|
|
{
|
|
name: "NoURISANFound",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
CommonName: "testuser", // Has CN but no URIs
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"URI"}, // Try to extract from URL but cert has no URIs
|
|
},
|
|
allowedUsers: []string{},
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized,
|
|
description: "No URI SAN found in certificate when URL is requested",
|
|
},
|
|
{
|
|
name: "InvalidURISANPattern",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
URIs: []string{"spiffe://example.org/workload/testuser"},
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"URI"},
|
|
URISANPattern: "[invalid(regex", // Invalid regex pattern
|
|
},
|
|
allowedUsers: []string{},
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized,
|
|
description: "Invalid URI SAN regex pattern",
|
|
},
|
|
{
|
|
name: "UnsupportedIdentitySource",
|
|
clientCertOptions: &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
},
|
|
mtlsConfig: &config.MTLSConfig{
|
|
IdentityAttibutes: []string{"InvalidSource"}, // Unsupported source
|
|
},
|
|
allowedUsers: []string{},
|
|
expectedIdentity: "",
|
|
expectedStatus: http.StatusUnauthorized,
|
|
description: "Unsupported identity source",
|
|
},
|
|
}
|
|
|
|
Convey("Test mTLS identity extraction", t, func() {
|
|
for _, tc := range testCases {
|
|
Convey(tc.description+" ("+tc.name+")", func() {
|
|
runMTLSTest(t, tc)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMTLSAuthentication(t *testing.T) {
|
|
// Create temporary directory for certificates
|
|
tempDir := t.TempDir()
|
|
|
|
// Generate CA certificate
|
|
caCert, caKey, err := tlsutils.GenerateCACert()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
caCertPath := path.Join(tempDir, "ca.crt")
|
|
err = os.WriteFile(caCertPath, caCert, 0o600)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Generate server certificate
|
|
serverCertPath := path.Join(tempDir, "server.crt")
|
|
serverKeyPath := path.Join(tempDir, "server.key")
|
|
opts := &tlsutils.CertificateOptions{
|
|
Hostname: "localhost",
|
|
}
|
|
err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Generate valid client certificate for "testuser" user
|
|
clientCertPath := path.Join(tempDir, "client.crt")
|
|
clientKeyPath := path.Join(tempDir, "client.key")
|
|
clientOpts := &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
}
|
|
err = tlsutils.GenerateClientCertToFile(caCert, caKey, clientCertPath, clientKeyPath, clientOpts)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Generate self-signed client cert for "testuser" user
|
|
selfSignedClientCertPath := path.Join(tempDir, "client-selfsigned.crt")
|
|
selfSignedClientKeyPath := path.Join(tempDir, "client-selfsigned.key")
|
|
selfSignedOpts := &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
}
|
|
err = tlsutils.GenerateClientSelfSignedCertToFile(selfSignedClientCertPath, selfSignedClientKeyPath, selfSignedOpts)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Create htpasswd file with sample "httpuser"
|
|
htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString("httpuser", "httppass"))
|
|
defer os.Remove(htpasswdPath)
|
|
|
|
Convey("Test mTLS-only authentication", t, func() {
|
|
// Set up server
|
|
conf := config.New()
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetSecureBaseURL(port)
|
|
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Groups: config.Groups{
|
|
"mtls-users": config.Group{
|
|
Users: []string{"testuser"},
|
|
},
|
|
},
|
|
Repositories: config.Repositories{
|
|
"**": config.PolicyGroup{ // Default restrict all
|
|
AnonymousPolicy: make([]string, 0),
|
|
Policies: make([]config.Policy, 0),
|
|
},
|
|
"test-repo": config.PolicyGroup{
|
|
Policies: []config.Policy{
|
|
{
|
|
Users: []string{"testuser"},
|
|
Actions: []string{"read", "create"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
conf.Storage.RootDirectory = t.TempDir()
|
|
|
|
ctlr := api.NewController(conf)
|
|
cm := test.NewControllerManager(ctlr)
|
|
|
|
cm.StartAndWait(port)
|
|
defer cm.StopServer()
|
|
|
|
// Test without client certificate - should fail
|
|
caCertPEM, err := os.ReadFile(caCertPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
client := resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS13})
|
|
resp, err := client.R().Get(baseURL + "/v2/test-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
|
|
|
// Test with valid client certificate - should succeed
|
|
clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
client = resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{clientCert},
|
|
RootCAs: caCertPool,
|
|
})
|
|
|
|
resp, err = client.R().Get(baseURL + "/v2/test-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth
|
|
|
|
// Test with self-signed client certificate - should fail
|
|
selfSignedClientCert, err := tls.LoadX509KeyPair(selfSignedClientCertPath, selfSignedClientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
client = resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{selfSignedClientCert},
|
|
RootCAs: caCertPool,
|
|
})
|
|
|
|
resp, err = client.R().Get(baseURL + "/v2/test-selfsigned-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
|
})
|
|
|
|
Convey("Test mTLS with basic auth and user/group access policies", t, func() {
|
|
// Set up server
|
|
conf := config.New()
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetSecureBaseURL(port)
|
|
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
conf.HTTP.Auth = &config.AuthConfig{
|
|
HTPasswd: config.AuthHTPasswd{
|
|
Path: htpasswdPath,
|
|
},
|
|
}
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Groups: config.Groups{
|
|
"mtls-users": config.Group{
|
|
Users: []string{"testuser"},
|
|
},
|
|
},
|
|
Repositories: config.Repositories{
|
|
"**": config.PolicyGroup{ // Default restrict all
|
|
AnonymousPolicy: make([]string, 0),
|
|
Policies: make([]config.Policy, 0),
|
|
},
|
|
"group-repo": config.PolicyGroup{
|
|
Policies: []config.Policy{
|
|
{
|
|
Groups: []string{"mtls-users"},
|
|
Actions: []string{"read", "create"},
|
|
},
|
|
},
|
|
},
|
|
"test-repo": config.PolicyGroup{
|
|
Policies: []config.Policy{
|
|
{
|
|
Users: []string{"testuser"},
|
|
Actions: []string{"read", "create"},
|
|
},
|
|
},
|
|
},
|
|
"htpasswd-repo": config.PolicyGroup{
|
|
Policies: []config.Policy{
|
|
{
|
|
Users: []string{"httpuser"},
|
|
Actions: []string{"read", "create"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
conf.Storage.RootDirectory = t.TempDir()
|
|
|
|
ctlr := api.NewController(conf)
|
|
cm := test.NewControllerManager(ctlr)
|
|
|
|
cm.StartAndWait(port)
|
|
defer cm.StopServer()
|
|
|
|
// Load server CA certificate
|
|
caCertPEM, err := os.ReadFile(caCertPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Load self-signed client certificate
|
|
selfSignedClientCert, err := tls.LoadX509KeyPair(selfSignedClientCertPath, selfSignedClientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Load valid client certificate with CN "testuser"
|
|
clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
// Tests without client certificate
|
|
client := resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS13})
|
|
resp, err := client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/htpasswd-repo/tags/list")
|
|
// Test without client CA but with htpasswd credentials - should pass because of valid htpasswd credentials
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth
|
|
|
|
// Tests with self-signed (== non-acceptable by server) client certificate
|
|
client = resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{selfSignedClientCert},
|
|
RootCAs: caCertPool,
|
|
})
|
|
|
|
// Test with self-signed client certificate - should still pass because of correct htpasswd auth
|
|
resp, err = client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/htpasswd-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth
|
|
|
|
// Tests with valid client certificate
|
|
client = resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{clientCert},
|
|
RootCAs: caCertPool,
|
|
})
|
|
// Tests with valid client cert and creds - should fail with 403 due to no permissions for user from basic auth
|
|
// This validates that identity from basic auth has higher priority over mTLS identity
|
|
resp, err = client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/test-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
|
|
|
|
// Test with correct auth credentials and different basic auth username from client certificate CN - should success
|
|
// This validates that identity from basic auth has higher priority over mTLS identity
|
|
resp, err = client.R().SetBasicAuth("httpuser", "httppass").Get(baseURL + "/v2/htpasswd-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth
|
|
|
|
// Should have access to test-repo for identity from client-cert
|
|
resp, err = client.R().Get(baseURL + "/v2/test-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth
|
|
|
|
// Should not have access to other repos for identity from client-cert
|
|
resp, err = client.R().Get(baseURL + "/v2/unauthorized-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
|
|
|
|
// Should have access to group-repo through group membership for identity from client-cert
|
|
resp, err = client.R().Get(baseURL + "/v2/group-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 meaning we successfully passed auth
|
|
})
|
|
}
|
|
|
|
func TestMTLSAuthenticationWithCertificateChain(t *testing.T) {
|
|
// Create temporary directory for certificates
|
|
tempDir := t.TempDir()
|
|
|
|
Convey("Test mTLS with certificate chain - uses leaf certificate identity", t, func() {
|
|
// Create certificate chain: Root CA -> Intermediate CA -> Client Certificate
|
|
// Generate root CA
|
|
rootCACert, rootCAKey, err := tlsutils.GenerateCACert()
|
|
So(err, ShouldBeNil)
|
|
rootCACertPath := path.Join(tempDir, "root-ca.crt")
|
|
err = os.WriteFile(rootCACertPath, rootCACert, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate intermediate CA (signed by root CA)
|
|
intermediateCAOpts := &tlsutils.CertificateOptions{
|
|
CommonName: "Intermediate CA",
|
|
}
|
|
intermediateCACert, intermediateCAKeyPEM, err := tlsutils.GenerateIntermediateCACert(
|
|
rootCACert, rootCAKey, intermediateCAOpts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate client certificate with CN signed by intermediate CA
|
|
clientWithCNOpts := &tlsutils.CertificateOptions{
|
|
CommonName: "clientuser",
|
|
}
|
|
clientCertWithCN, clientKeyWithCN, err := tlsutils.GenerateClientCert(
|
|
intermediateCACert, intermediateCAKeyPEM, clientWithCNOpts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate client certificate without CN signed by intermediate CA
|
|
clientWithoutCNOpts := &tlsutils.CertificateOptions{
|
|
// No CommonName - empty to test that identity is not taken from intermediate CA
|
|
}
|
|
clientCertWithoutCN, clientKeyWithoutCNPEM, err := tlsutils.GenerateClientCert(
|
|
intermediateCACert, intermediateCAKeyPEM, clientWithoutCNOpts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate server certificate signed by root CA for this test
|
|
serverCertForChainPath := path.Join(tempDir, "server-chain.crt")
|
|
serverKeyForChainPath := path.Join(tempDir, "server-chain.key")
|
|
serverOpts := &tlsutils.CertificateOptions{
|
|
Hostname: "localhost",
|
|
}
|
|
err = tlsutils.GenerateServerCertToFile(
|
|
rootCACert, rootCAKey, serverCertForChainPath, serverKeyForChainPath, serverOpts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Set up server with root CA
|
|
conf := config.New()
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetSecureBaseURL(port)
|
|
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertForChainPath,
|
|
Key: serverKeyForChainPath,
|
|
CACert: rootCACertPath, // Server trusts root CA
|
|
}
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Repositories: config.Repositories{
|
|
"**": config.PolicyGroup{
|
|
AnonymousPolicy: make([]string, 0),
|
|
Policies: make([]config.Policy, 0),
|
|
},
|
|
"client-repo": config.PolicyGroup{
|
|
Policies: []config.Policy{
|
|
{
|
|
Users: []string{"clientuser"},
|
|
Actions: []string{"read", "create"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
conf.Storage.RootDirectory = t.TempDir()
|
|
|
|
ctlr := api.NewController(conf)
|
|
cm := test.NewControllerManager(ctlr)
|
|
|
|
cm.StartAndWait(port)
|
|
defer cm.StopServer()
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(rootCACert)
|
|
|
|
// Test 1: Client cert with CN in chain - should use client cert CN, not intermediate CA CN
|
|
clientCertWithCNPath := path.Join(tempDir, "client-with-cn.crt")
|
|
clientKeyWithCNPath := path.Join(tempDir, "client-with-cn.key")
|
|
err = os.WriteFile(clientCertWithCNPath, clientCertWithCN, 0o600)
|
|
So(err, ShouldBeNil)
|
|
err = os.WriteFile(clientKeyWithCNPath, clientKeyWithCN, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create certificate chain file (client cert + intermediate CA)
|
|
chainCertPath := path.Join(tempDir, "client-with-cn-chain.crt")
|
|
err = tlsutils.WriteCertificateChainToFile(chainCertPath, clientCertWithCN, intermediateCACert)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Load certificate chain
|
|
clientCertChain, err := tls.LoadX509KeyPair(chainCertPath, clientKeyWithCNPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
client := resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{clientCertChain},
|
|
RootCAs: caCertPool,
|
|
})
|
|
|
|
// Should succeed because client cert has CN "clientuser" which matches policy
|
|
resp, err := client.R().Get(baseURL + "/v2/client-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound) // 404 means auth passed
|
|
|
|
// Test 2: Client cert without CN in chain - should fail, not use intermediate CA CN
|
|
clientCertWithoutCNPath := path.Join(tempDir, "client-without-cn.crt")
|
|
clientKeyWithoutCNPath := path.Join(tempDir, "client-without-cn.key")
|
|
err = os.WriteFile(clientCertWithoutCNPath, clientCertWithoutCN, 0o600)
|
|
So(err, ShouldBeNil)
|
|
err = os.WriteFile(clientKeyWithoutCNPath, clientKeyWithoutCNPEM, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create certificate chain file (client cert without CN + intermediate CA)
|
|
chainCertWithoutCNPath := path.Join(tempDir, "client-without-cn-chain.crt")
|
|
err = tlsutils.WriteCertificateChainToFile(chainCertWithoutCNPath, clientCertWithoutCN, intermediateCACert)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Load certificate chain
|
|
clientCertChainWithoutCN, err := tls.LoadX509KeyPair(chainCertWithoutCNPath, clientKeyWithoutCNPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
client = resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{clientCertChainWithoutCN},
|
|
RootCAs: caCertPool,
|
|
})
|
|
|
|
// Should fail because client cert has no CN, even though intermediate CA has CN
|
|
resp, err = client.R().Get(baseURL + "/v2/client-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
|
})
|
|
}
|
|
|
|
func TestMTLSAuthenticationWithExpiredCertificate(t *testing.T) {
|
|
// Create temporary directory for certificates
|
|
tempDir := t.TempDir()
|
|
|
|
Convey("Test mTLS authentication with expired certificate", t, func() {
|
|
// Generate CA certificate
|
|
caCert, caKey, err := tlsutils.GenerateCACert()
|
|
So(err, ShouldBeNil)
|
|
caCertPath := path.Join(tempDir, "ca.crt")
|
|
err = os.WriteFile(caCertPath, caCert, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate server certificate
|
|
serverCertPath := path.Join(tempDir, "server.crt")
|
|
serverKeyPath := path.Join(tempDir, "server.key")
|
|
opts := &tlsutils.CertificateOptions{
|
|
Hostname: "localhost",
|
|
}
|
|
err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate expired client certificate (NotAfter is in the past)
|
|
expiredClientCertPath := path.Join(tempDir, "client-expired.crt")
|
|
expiredClientKeyPath := path.Join(tempDir, "client-expired.key")
|
|
expiredOpts := &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
NotBefore: time.Now().Add(-365 * 24 * time.Hour), // 1 year ago
|
|
NotAfter: time.Now().Add(-24 * time.Hour), // 1 day ago (expired)
|
|
}
|
|
err = tlsutils.GenerateClientCertToFile(caCert, caKey, expiredClientCertPath, expiredClientKeyPath, expiredOpts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Set up server
|
|
conf := config.New()
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetSecureBaseURL(port)
|
|
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Repositories: config.Repositories{
|
|
"**": config.PolicyGroup{
|
|
AnonymousPolicy: make([]string, 0),
|
|
Policies: make([]config.Policy, 0),
|
|
},
|
|
"test-repo": config.PolicyGroup{
|
|
Policies: []config.Policy{
|
|
{
|
|
Users: []string{"testuser"},
|
|
Actions: []string{"read", "create"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
conf.Storage.RootDirectory = t.TempDir()
|
|
|
|
ctlr := api.NewController(conf)
|
|
cm := test.NewControllerManager(ctlr)
|
|
|
|
cm.StartAndWait(port)
|
|
defer cm.StopServer()
|
|
|
|
// Set up client with expired certificate
|
|
caCertPEM, err := os.ReadFile(caCertPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
expiredClientCert, err := tls.LoadX509KeyPair(expiredClientCertPath, expiredClientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
client := resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{expiredClientCert},
|
|
RootCAs: caCertPool,
|
|
})
|
|
|
|
// Expired certificate should be rejected at TLS handshake level
|
|
// The TLS stack will reject it before it reaches the application layer
|
|
_, err = client.R().Get(baseURL + "/v2/test-repo/tags/list")
|
|
// Error is expected - TLS handshake fails with expired certificate
|
|
So(err, ShouldNotBeNil)
|
|
So(err.Error(), ShouldContainSubstring, "expired certificate")
|
|
})
|
|
}
|
|
|
|
func TestMTLSAuthenticationWithUnknownCA(t *testing.T) {
|
|
// Create temporary directory for certificates
|
|
tempDir := t.TempDir()
|
|
|
|
Convey("Test mTLS authentication with certificate signed by unknown CA", t, func() {
|
|
// Generate server CA and certificate
|
|
serverCACert, serverCAKey, err := tlsutils.GenerateCACert()
|
|
So(err, ShouldBeNil)
|
|
serverCACertPath := path.Join(tempDir, "server-ca.crt")
|
|
err = os.WriteFile(serverCACertPath, serverCACert, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
serverCertPath := path.Join(tempDir, "server.crt")
|
|
serverKeyPath := path.Join(tempDir, "server.key")
|
|
opts := &tlsutils.CertificateOptions{
|
|
Hostname: "localhost",
|
|
}
|
|
err = tlsutils.GenerateServerCertToFile(serverCACert, serverCAKey, serverCertPath, serverKeyPath, opts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate a different CA (unknown to the server) and client certificate
|
|
unknownCACert, unknownCAKey, err := tlsutils.GenerateCACert()
|
|
So(err, ShouldBeNil)
|
|
|
|
unknownClientCertPath := path.Join(tempDir, "client-unknown-ca.crt")
|
|
unknownClientKeyPath := path.Join(tempDir, "client-unknown-ca.key")
|
|
clientOpts := &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
}
|
|
err = tlsutils.GenerateClientCertToFile(unknownCACert, unknownCAKey, unknownClientCertPath,
|
|
unknownClientKeyPath, clientOpts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Set up server with server CA (doesn't know about unknown CA)
|
|
conf := config.New()
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetSecureBaseURL(port)
|
|
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: serverCACertPath, // Server only trusts serverCACert, not unknownCACert
|
|
}
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Repositories: config.Repositories{
|
|
"**": config.PolicyGroup{
|
|
AnonymousPolicy: make([]string, 0),
|
|
Policies: make([]config.Policy, 0),
|
|
},
|
|
"test-repo": config.PolicyGroup{
|
|
Policies: []config.Policy{
|
|
{
|
|
Users: []string{"testuser"},
|
|
Actions: []string{"read", "create"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
conf.Storage.RootDirectory = t.TempDir()
|
|
|
|
ctlr := api.NewController(conf)
|
|
cm := test.NewControllerManager(ctlr)
|
|
|
|
cm.StartAndWait(port)
|
|
defer cm.StopServer()
|
|
|
|
// Set up client with certificate signed by unknown CA
|
|
serverCACertPEM, err := os.ReadFile(serverCACertPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(serverCACertPEM)
|
|
|
|
unknownClientCert, err := tls.LoadX509KeyPair(unknownClientCertPath, unknownClientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
client := resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{unknownClientCert},
|
|
RootCAs: caCertPool,
|
|
})
|
|
|
|
// Certificate signed by unknown CA should be rejected at TLS handshake level
|
|
// The TLS stack will reject it before it reaches the application layer
|
|
_, err = client.R().Get(baseURL + "/v2/test-repo/tags/list")
|
|
// Error is expected - TLS handshake fails with unknown certificate authority
|
|
So(err, ShouldNotBeNil)
|
|
So(err.Error(), ShouldContainSubstring, "unknown certificate authority")
|
|
})
|
|
}
|
|
|
|
func TestMTLSAuthenticationWithMetaDBError(t *testing.T) {
|
|
// Create temporary directory for certificates
|
|
tempDir := t.TempDir()
|
|
|
|
Convey("Test mTLS authentication with MetaDB.SetUserGroups error", t, func() {
|
|
// Generate CA certificate
|
|
caCert, caKey, err := tlsutils.GenerateCACert()
|
|
So(err, ShouldBeNil)
|
|
caCertPath := path.Join(tempDir, "ca.crt")
|
|
err = os.WriteFile(caCertPath, caCert, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate server certificate
|
|
serverCertPath := path.Join(tempDir, "server.crt")
|
|
serverKeyPath := path.Join(tempDir, "server.key")
|
|
opts := &tlsutils.CertificateOptions{
|
|
Hostname: "localhost",
|
|
}
|
|
err = tlsutils.GenerateServerCertToFile(caCert, caKey, serverCertPath, serverKeyPath, opts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Generate valid client certificate for "testuser" user
|
|
clientCertPath := path.Join(tempDir, "client.crt")
|
|
clientKeyPath := path.Join(tempDir, "client.key")
|
|
clientOpts := &tlsutils.CertificateOptions{
|
|
CommonName: "testuser",
|
|
}
|
|
err = tlsutils.GenerateClientCertToFile(caCert, caKey, clientCertPath, clientKeyPath, clientOpts)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Set up server
|
|
conf := config.New()
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetSecureBaseURL(port)
|
|
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Groups: config.Groups{
|
|
"mtls-users": config.Group{
|
|
Users: []string{"testuser"},
|
|
},
|
|
},
|
|
Repositories: config.Repositories{
|
|
"**": config.PolicyGroup{
|
|
AnonymousPolicy: make([]string, 0),
|
|
Policies: make([]config.Policy, 0),
|
|
},
|
|
"test-repo": config.PolicyGroup{
|
|
Policies: []config.Policy{
|
|
{
|
|
Users: []string{"testuser"},
|
|
Actions: []string{"read", "create"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
conf.Storage.RootDirectory = t.TempDir()
|
|
|
|
ctlr := api.NewController(conf)
|
|
cm := test.NewControllerManager(ctlr)
|
|
|
|
cm.StartAndWait(port)
|
|
defer cm.StopServer()
|
|
|
|
// Set up client with valid certificate
|
|
caCertPEM, err := os.ReadFile(caCertPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
client := resty.New()
|
|
client.SetTLSClientConfig(&tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
Certificates: []tls.Certificate{clientCert},
|
|
RootCAs: caCertPool,
|
|
})
|
|
|
|
// Mock MetaDB to return error on SetUserGroups
|
|
ctlr.MetaDB = mocks.MetaDBMock{
|
|
SetUserGroupsFn: func(ctx context.Context, groups []string) error {
|
|
return ErrUnexpectedError
|
|
},
|
|
}
|
|
|
|
// Should return 500 Internal Server Error due to MetaDB error
|
|
resp, err := client.R().Get(baseURL + "/v2/test-repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusInternalServerError)
|
|
})
|
|
}
|
|
|
|
func TestMutualTLSAuthWithUserPermissions(t *testing.T) {
|
|
Convey("Make a new controller", t, func() {
|
|
caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
secureBaseURL := test.GetSecureBaseURL(port)
|
|
|
|
resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12})
|
|
|
|
defer func() { resty.SetTLSClientConfig(nil) }()
|
|
|
|
conf := config.New()
|
|
conf.HTTP.Port = port
|
|
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Repositories: config.Repositories{
|
|
test.AuthorizationAllRepos: config.PolicyGroup{
|
|
Policies: []config.Policy{
|
|
{
|
|
Users: []string{"testclient"},
|
|
Actions: []string{"read"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
ctlr := api.NewController(conf)
|
|
ctlr.Config.Storage.RootDirectory = t.TempDir()
|
|
|
|
cm := test.NewControllerManager(ctlr)
|
|
cm.StartAndWait(port)
|
|
|
|
defer cm.StopServer()
|
|
|
|
resp, err := resty.R().Get(baseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
|
|
|
repoPolicy := conf.HTTP.AccessControl.Repositories[test.AuthorizationAllRepos]
|
|
|
|
// setup TLS mutual auth
|
|
cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates()
|
|
client := resty.New().SetTLSClientConfig(&tls.Config{
|
|
RootCAs: caCertPool,
|
|
MinVersion: tls.VersionTLS12,
|
|
Certificates: []tls.Certificate{cert},
|
|
})
|
|
|
|
// with client certs but without creds, should succeed
|
|
resp, err = client.R().Get(secureBaseURL + "/v2/")
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
|
|
|
resp, err = client.R().Get(secureBaseURL + "/v2/_catalog")
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
|
|
|
// with creds, should get expected status code
|
|
resp, _ = client.R().Get(secureBaseURL)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound)
|
|
|
|
// reading a repo should not get 403
|
|
resp, err = client.R().Get(secureBaseURL + "/v2/repo/tags/list")
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound)
|
|
|
|
// without creds, writes should fail
|
|
resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
|
|
|
|
// empty default authorization and give user the permission to create
|
|
repoPolicy.Policies[0].Actions = append(repoPolicy.Policies[0].Actions, "create")
|
|
conf.HTTP.AccessControl.Repositories[test.AuthorizationAllRepos] = repoPolicy
|
|
resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusAccepted)
|
|
})
|
|
}
|
|
|
|
func TestTLSMutualAuth(t *testing.T) {
|
|
Convey("Make a new controller", t, func() {
|
|
caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
secureBaseURL := test.GetSecureBaseURL(port)
|
|
|
|
conf := config.New()
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
|
|
ctlr := api.NewController(conf)
|
|
ctlr.Config.Storage.RootDirectory = t.TempDir()
|
|
|
|
cm := test.NewControllerManager(ctlr)
|
|
cm.StartAndWait(port)
|
|
|
|
defer cm.StopServer()
|
|
|
|
// access without any certificate settings
|
|
client := resty.New()
|
|
|
|
// accessing insecure HTTP site should fail
|
|
resp, err := client.R().Get(baseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
|
|
|
// without client certs and creds, should get certificate verification error
|
|
_, err = client.R().Get(secureBaseURL)
|
|
So(err, ShouldNotBeNil)
|
|
|
|
// without client certs should fail auth
|
|
_, err = client.R().Get(secureBaseURL + "/v2/")
|
|
So(err, ShouldNotBeNil)
|
|
|
|
// Use resty client with certificates,
|
|
client = resty.New().SetTLSClientConfig(&tls.Config{
|
|
RootCAs: caCertPool,
|
|
MinVersion: tls.VersionTLS12,
|
|
})
|
|
|
|
// without client certs should fail auth
|
|
resp, err = client.R().Get(secureBaseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound)
|
|
|
|
// without client certs should fail auth
|
|
resp, _ = client.R().Get(secureBaseURL + "/v2/")
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
|
|
|
username, seedUser := test.GenerateRandomString()
|
|
password, seedPass := test.GenerateRandomString()
|
|
ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password")
|
|
|
|
resp, err = client.R().SetBasicAuth(username, password).Get(secureBaseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound)
|
|
|
|
// with only creds, should get 401 because basic auth is disabled
|
|
// (Authorization header should be rejected when the auth method is disabled, regardless of mTLS)
|
|
resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/")
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
|
|
|
// setup TLS mutual auth
|
|
cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
client = resty.New().SetTLSClientConfig(&tls.Config{
|
|
RootCAs: caCertPool,
|
|
MinVersion: tls.VersionTLS12,
|
|
Certificates: []tls.Certificate{cert},
|
|
})
|
|
|
|
// with client certs but without creds, should succeed
|
|
resp, err = client.R().Get(secureBaseURL + "/v2/")
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
|
|
|
resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound)
|
|
|
|
// with client certs and creds, should get 401 because basic auth is disabled
|
|
// (Authorization header should be rejected when the auth method is disabled, regardless of mTLS)
|
|
resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/")
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
|
})
|
|
}
|
|
|
|
func TestTLSMutualAuthAllowReadAccess(t *testing.T) {
|
|
Convey("Make a new controller", t, func() {
|
|
caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
secureBaseURL := test.GetSecureBaseURL(port)
|
|
|
|
// Use resty client with certificates,
|
|
client := resty.New().SetTLSClientConfig(&tls.Config{
|
|
RootCAs: caCertPool,
|
|
MinVersion: tls.VersionTLS12,
|
|
})
|
|
|
|
conf := config.New()
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Repositories: config.Repositories{
|
|
test.AuthorizationAllRepos: config.PolicyGroup{
|
|
AnonymousPolicy: []string{"read"},
|
|
},
|
|
},
|
|
}
|
|
|
|
ctlr := api.NewController(conf)
|
|
ctlr.Config.Storage.RootDirectory = t.TempDir()
|
|
|
|
cm := test.NewControllerManager(ctlr)
|
|
cm.StartAndWait(port)
|
|
|
|
defer cm.StopServer()
|
|
|
|
// accessing insecure HTTP site should fail
|
|
resp, err := client.R().Get(baseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
|
|
|
// without client certs and creds, reads are allowed
|
|
resp, err = client.R().Get(secureBaseURL + "/v2/")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
|
|
|
username, seedUser := test.GenerateRandomString()
|
|
password, seedPass := test.GenerateRandomString()
|
|
|
|
ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password")
|
|
// with creds but without certs, reads are not allowed as server does not use basic auth
|
|
// and basic auth headers are expected to contain valid credentials
|
|
resp, err = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
|
|
|
// without creds, writes should fail
|
|
resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
|
|
|
// setup TLS mutual auth
|
|
cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates()
|
|
client = resty.New().SetTLSClientConfig(&tls.Config{
|
|
RootCAs: caCertPool,
|
|
MinVersion: tls.VersionTLS12,
|
|
Certificates: []tls.Certificate{cert},
|
|
})
|
|
|
|
// with client certs but without creds, should succeed
|
|
resp, _ = client.R().Get(secureBaseURL)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound)
|
|
|
|
// with client certs but without creds, should succeed
|
|
resp, err = client.R().Get(secureBaseURL + "/v2/")
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
|
|
|
// with client certs and creds, reads are not allowed as server does not use basic auth
|
|
// and basic auth headers are expected to contain valid credentials
|
|
resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound)
|
|
|
|
// with client certs, reads are not allowed as server does not use basic auth
|
|
resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/")
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
|
|
})
|
|
}
|
|
|
|
func TestTLSMutualAndBasicAuth(t *testing.T) {
|
|
Convey("Make a new controller", t, func() {
|
|
caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
username, seedUser := test.GenerateRandomString()
|
|
password, seedPass := test.GenerateRandomString()
|
|
|
|
htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString(username, password))
|
|
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
secureBaseURL := test.GetSecureBaseURL(port)
|
|
|
|
resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12})
|
|
|
|
defer func() { resty.SetTLSClientConfig(nil) }()
|
|
|
|
conf := config.New()
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.Auth = &config.AuthConfig{
|
|
HTPasswd: config.AuthHTPasswd{
|
|
Path: htpasswdPath,
|
|
},
|
|
}
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
|
|
ctlr := api.NewController(conf)
|
|
ctlr.Config.Storage.RootDirectory = t.TempDir()
|
|
ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password")
|
|
|
|
cm := test.NewControllerManager(ctlr)
|
|
cm.StartAndWait(port)
|
|
|
|
defer cm.StopServer()
|
|
|
|
// accessing insecure HTTP site should fail
|
|
resp, err := resty.R().Get(baseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
|
|
|
// without client certs and creds, should fail
|
|
_, err = resty.R().Get(secureBaseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
|
|
|
// with creds but without certs, should succeed
|
|
_, err = resty.R().SetBasicAuth(username, password).Get(secureBaseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
|
|
|
// setup TLS mutual auth
|
|
cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates()
|
|
client := resty.New().SetTLSClientConfig(&tls.Config{
|
|
RootCAs: caCertPool,
|
|
MinVersion: tls.VersionTLS12,
|
|
Certificates: []tls.Certificate{cert},
|
|
})
|
|
|
|
// with client certs but without creds, succeed because mTLS is used for auth when no auth headers provided
|
|
resp, err = client.R().Get(secureBaseURL + "/v2/")
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
|
|
|
// with client certs and creds, should get expected status code
|
|
resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound)
|
|
|
|
resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/")
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
|
})
|
|
}
|
|
|
|
func TestTLSMutualAndBasicAuthAllowReadAccess(t *testing.T) {
|
|
Convey("Make a new controller", t, func() {
|
|
caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath, caCertPEM := setupTestCerts(t)
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCertPEM)
|
|
|
|
username, seedUser := test.GenerateRandomString()
|
|
password, seedPass := test.GenerateRandomString()
|
|
|
|
htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString(username, password))
|
|
|
|
port := test.GetFreePort()
|
|
baseURL := test.GetBaseURL(port)
|
|
secureBaseURL := test.GetSecureBaseURL(port)
|
|
|
|
resty.SetTLSClientConfig(&tls.Config{RootCAs: caCertPool, MinVersion: tls.VersionTLS12})
|
|
|
|
defer func() { resty.SetTLSClientConfig(nil) }()
|
|
|
|
conf := config.New()
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.Auth = &config.AuthConfig{
|
|
HTPasswd: config.AuthHTPasswd{
|
|
Path: htpasswdPath,
|
|
},
|
|
}
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
|
|
conf.HTTP.AccessControl = &config.AccessControlConfig{
|
|
Repositories: config.Repositories{
|
|
test.AuthorizationAllRepos: config.PolicyGroup{
|
|
AnonymousPolicy: []string{"read"},
|
|
},
|
|
},
|
|
}
|
|
|
|
ctlr := api.NewController(conf)
|
|
ctlr.Config.Storage.RootDirectory = t.TempDir()
|
|
ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password")
|
|
|
|
cm := test.NewControllerManager(ctlr)
|
|
cm.StartAndWait(port)
|
|
|
|
defer cm.StopServer()
|
|
|
|
// accessing insecure HTTP site should fail
|
|
resp, err := resty.R().Get(baseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
|
|
|
// without client certs and creds, should fail
|
|
_, err = resty.R().Get(secureBaseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
|
|
|
// with creds but without certs, should succeed
|
|
_, err = resty.R().SetBasicAuth(username, password).Get(secureBaseURL)
|
|
So(err, ShouldBeNil)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
|
|
|
|
// setup TLS mutual auth
|
|
cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Use separate resty client with certificates, because we cannot perform cleanup with resty.SetCertificates()
|
|
client := resty.New().SetTLSClientConfig(&tls.Config{
|
|
RootCAs: caCertPool,
|
|
MinVersion: tls.VersionTLS12,
|
|
Certificates: []tls.Certificate{cert},
|
|
})
|
|
|
|
// with client certs but without creds, reads should succeed
|
|
resp, err = client.R().Get(secureBaseURL + "/v2/")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
|
|
|
// with only client certs, writes should fail with insufficient permissions
|
|
resp, err = client.R().Post(secureBaseURL + "/v2/repo/blobs/uploads/")
|
|
So(err, ShouldBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusForbidden)
|
|
|
|
// with client certs and creds, should get expected status code
|
|
resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL)
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusNotFound)
|
|
|
|
resp, _ = client.R().SetBasicAuth(username, password).Get(secureBaseURL + "/v2/")
|
|
So(resp, ShouldNotBeNil)
|
|
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
|
|
})
|
|
}
|
|
|
|
func TestTSLFailedReadingOfCACert(t *testing.T) {
|
|
Convey("no permissions", t, func() {
|
|
caCertPath, serverCertPath, serverKeyPath, _, _, _ := setupTestCerts(t)
|
|
port := test.GetFreePort()
|
|
conf := config.New()
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: caCertPath,
|
|
}
|
|
|
|
err := os.Chmod(caCertPath, 0o000)
|
|
defer func() {
|
|
err := os.Chmod(caCertPath, 0o644)
|
|
So(err, ShouldBeNil)
|
|
}()
|
|
So(err, ShouldBeNil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
ctlr := api.NewController(conf)
|
|
ctlr.Config.Storage.RootDirectory = t.TempDir()
|
|
|
|
err = ctlr.Init()
|
|
So(err, ShouldBeNil)
|
|
|
|
errChan := make(chan error, 1)
|
|
|
|
go func() {
|
|
err = ctlr.Run()
|
|
errChan <- err
|
|
}()
|
|
|
|
testTimeout := false
|
|
|
|
select {
|
|
case err := <-errChan:
|
|
So(err, ShouldNotBeNil)
|
|
case <-ctx.Done():
|
|
testTimeout = true
|
|
|
|
cancel()
|
|
}
|
|
|
|
So(testTimeout, ShouldBeFalse)
|
|
})
|
|
|
|
Convey("empty CACert", t, func() {
|
|
badCACert := filepath.Join(t.TempDir(), "badCACert")
|
|
err := os.WriteFile(badCACert, []byte(""), 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
_, serverCertPath, serverKeyPath, _, _, _ := setupTestCerts(t)
|
|
port := test.GetFreePort()
|
|
conf := config.New()
|
|
conf.HTTP.Port = port
|
|
conf.HTTP.TLS = &config.TLSConfig{
|
|
Cert: serverCertPath,
|
|
Key: serverKeyPath,
|
|
CACert: badCACert,
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
ctlr := api.NewController(conf)
|
|
ctlr.Config.Storage.RootDirectory = t.TempDir()
|
|
|
|
err = ctlr.Init()
|
|
So(err, ShouldBeNil)
|
|
|
|
errChan := make(chan error, 1)
|
|
|
|
go func() {
|
|
err = ctlr.Run()
|
|
errChan <- err
|
|
}()
|
|
|
|
testTimeout := false
|
|
|
|
select {
|
|
case err := <-errChan:
|
|
So(err, ShouldNotBeNil)
|
|
case <-ctx.Done():
|
|
testTimeout = true
|
|
|
|
cancel()
|
|
}
|
|
|
|
So(testTimeout, ShouldBeFalse)
|
|
})
|
|
}
|