diff --git a/docs/source/admin/quick_howto/client_cert_auth.rst b/docs/source/admin/quick_howto/client_cert_auth.rst new file mode 100644 index 0000000000..72b754dd36 --- /dev/null +++ b/docs/source/admin/quick_howto/client_cert_auth.rst @@ -0,0 +1,25 @@ +.. +.. +.. Licensed under the Apache License, Version 2.0 (the "License"); +.. you may not use this file except in compliance with the License. +.. You may obtain a copy of the License at +.. +.. http://www.apache.org/licenses/LICENSE-2.0 +.. +.. Unless required by applicable law or agreed to in writing, software +.. distributed under the License is distributed on an "AS IS" BASIS, +.. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +.. See the License for the specific language governing permissions and +.. limitations under the License. +.. + +.. _client-cert-auth: + +************************************** +Client Certificates for Authentication +************************************** + +An alternative mechanism for providing credentials and authenticating access. + +There are multiple mechanisms, specifically within Traffic Ops, that provide a means for authentication. + diff --git a/docs/source/admin/traffic_ops.rst b/docs/source/admin/traffic_ops.rst index 0a3c28c66c..9e3c58fd47 100644 --- a/docs/source/admin/traffic_ops.rst +++ b/docs/source/admin/traffic_ops.rst @@ -320,6 +320,12 @@ This file deals with the configuration parameters of running Traffic Ops itself. :renew_days_before_expiration: Set the number of days before expiration date to renew certificates. :summary_email: The email address to use for summarizing certificate expiration and renewal status. If it is blank, no email will be sent. +:client_certificate_authentication: This is an optional section of configurations client provided certificate based authentication. However, if ``"ClientAuth" : "1"``` is enabled in the ``tls_config`` section in ``traffic_ops_golang``, then this field is required. + + .. versionadded:: 7.0 + + :root_certificates_directory: A string representing the absolute path of the directory where Root CA certificates are located. These Root CA certificates are used for verifying the certificate provided by the client. + :default_certificate_info: This is an optional object to define default values when generating a self signed certificate when an HTTPS delivery service is created or updated. If this is an empty object or not present in the :ref:`cdn.conf` then the term "Placeholder" will be used for all fields. :business_unit: An optional field which, if present, will represent the business unit for which the SSL certificate was generated @@ -517,7 +523,6 @@ This file deals with the configuration parameters of running Traffic Ops itself. .. versionadded:: 7.0 - Example cdn.conf '''''''''''''''' .. include:: ../../../traffic_ops/app/conf/cdn.conf diff --git a/experimental/certificate_auth/certs/generate_certs.go b/experimental/certificate_auth/certs/generate_certs.go new file mode 100644 index 0000000000..3c0682b3e4 --- /dev/null +++ b/experimental/certificate_auth/certs/generate_certs.go @@ -0,0 +1,446 @@ +package main + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "flag" + "fmt" + "io/ioutil" + "log" + "math" + "math/big" + "net" + "time" +) + +// CertificateKeyPair contains the parsed representation of a certificate +// and private key. +type CertificateKeyPair struct { + Certificate *x509.Certificate + PrivateKey *rsa.PrivateKey +} + +// CertificatePEMPair contains the PEM encoded certificate and private key. +type CertificatePEMPair struct { + CertificatePEM, PrivateKeyPEM string +} + +var ( + rootCN = "root.local" + interCN = "intermediate.local" + clientCN = "client.local" + serverCN = "server.local" + + uid = "userid" + // useEcdsa = false //TODO: Enable and refactor +) + +func main() { + flag.StringVar(&uid, "uid", uid, "[Optional] The User ID value to be added to the client certificate") + // flag.BoolVar(&useEcdsa, "useEcdsa", useEcdsa, "[Optional] Use ECDSA 256 when generating the keys. Default is RSA 4096") + + flag.Parse() + + rootCAPEMPair, err := GenerateRootCACertificate() + if err != nil { + log.Fatalf("Failed to generate and sign Root CA certificate\nErr: %s\n", err) + } + ioutil.WriteFile("rootca.crt.pem", []byte(rootCAPEMPair.CertificatePEM), 0644) + ioutil.WriteFile("rootca.key.pem", []byte(rootCAPEMPair.PrivateKeyPEM), 0644) + + intermediatePEMPair, err := GenerateIntermediateCertificate(rootCAPEMPair) + if err != nil { + log.Fatalf("Failed to generate and sign Intermediate certificate\nErr: %s\n", err) + } + ioutil.WriteFile("intermediate.crt.pem", []byte(intermediatePEMPair.CertificatePEM), 0644) + ioutil.WriteFile("intermediate.key.pem", []byte(intermediatePEMPair.PrivateKeyPEM), 0644) + + serverPEMPair, err := GenerateServerCertificate(intermediatePEMPair) + if err != nil { + log.Fatalf("Failed to generate and sign Server certificate\nErr: %s\n", err) + } + + ioutil.WriteFile("server.crt.pem", []byte(serverPEMPair.CertificatePEM), 0644) + ioutil.WriteFile("server.key.pem", []byte(serverPEMPair.PrivateKeyPEM), 0644) + + clientPEMPair, err := GenerateClientCertificate(intermediatePEMPair) + if err != nil { + log.Fatalf("Failed to generate and sign Client certificate\nErr: %s\n", err) + } + + ioutil.WriteFile("client.crt.pem", []byte(clientPEMPair.CertificatePEM), 0644) + ioutil.WriteFile("client.key.pem", []byte(clientPEMPair.PrivateKeyPEM), 0644) + + clientIntermediateChain := clientPEMPair.CertificatePEM + intermediatePEMPair.CertificatePEM + ioutil.WriteFile("client-intermediate-chain.crt.pem", []byte(clientIntermediateChain), 0644) + + if err := VerifyCertificates(rootCAPEMPair, intermediatePEMPair, clientPEMPair, serverPEMPair); err != nil { + log.Fatalf("failed to verify certificate: %s", err) + } +} + +// ParseCertificateKeyPair decodes the provided PEM pair (key, cert) and returns a +// parsed private key and x509 certificate. +func ParseCertificateKeyPair(pemPair *CertificatePEMPair) (*CertificateKeyPair, error) { + + keyPair := new(CertificateKeyPair) + + privPemBlock, _ := pem.Decode([]byte(pemPair.PrivateKeyPEM)) + + privateKey, err := x509.ParsePKCS8PrivateKey(privPemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + keyPair.PrivateKey = privateKey.(*rsa.PrivateKey) + + certPemBlock, _ := pem.Decode([]byte(pemPair.CertificatePEM)) + + certificate, err := x509.ParseCertificate(certPemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %w", err) + } + + keyPair.Certificate = certificate + + return keyPair, nil +} + +// GenereateRootCACertificate creates a Root CA certificate that can be used +// for signing intermediate, client, and server x509 certificates. +func GenerateRootCACertificate() (*CertificatePEMPair, error) { + + now := time.Now() + + serialNumber, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + + cert := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + OrganizationalUnit: []string{"ATC"}, + Organization: []string{"Apache"}, + Country: []string{"US"}, + Province: []string{"Colorado"}, + Locality: []string{"Denver"}, + CommonName: rootCN, + }, + NotBefore: now, + NotAfter: now.AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + + certDERBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, &certPrivKey.PublicKey, certPrivKey) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %w", err) + } + + certPEMPair := new(CertificatePEMPair) + + certPEM := new(bytes.Buffer) + pem.Encode(certPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certDERBytes, + }) + + certPrivKeyPEM := new(bytes.Buffer) + certPrivKeyByes, err := x509.MarshalPKCS8PrivateKey(certPrivKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal private key to PKCS8: %w", err) + } + + pem.Encode(certPrivKeyPEM, &pem.Block{ + Type: "PRIVATE KEY", + Bytes: certPrivKeyByes, + }) + + certPEMPair.CertificatePEM = certPEM.String() + certPEMPair.PrivateKeyPEM = certPrivKeyPEM.String() + + return certPEMPair, nil +} + +// GenerateIntermediateCeertificate creates an intermediate based on the provided Root certificate. +// This certificate can be used for signing client and server certificates to establish +// a chain to the Root certificate. +func GenerateIntermediateCertificate(root *CertificatePEMPair) (*CertificatePEMPair, error) { + + rootKeyPair, err := ParseCertificateKeyPair(root) + if err != nil { + log.Fatalln("Failed to parse root cert and key") + } + + now := time.Now() + + serialNumber, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + + cert := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + OrganizationalUnit: []string{"ATC"}, + Organization: []string{"Apache"}, + Country: []string{"US"}, + Province: []string{"Colorado"}, + Locality: []string{"Denver"}, + CommonName: interCN, + }, + NotBefore: now, + NotAfter: now.AddDate(1, 0, 0), + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + MaxPathLenZero: true, + IsCA: true, + } + + certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + + certDERBytes, err := x509.CreateCertificate(rand.Reader, cert, rootKeyPair.Certificate, &certPrivKey.PublicKey, rootKeyPair.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %w", err) + } + + certPEMPair := new(CertificatePEMPair) + + certPEM := new(bytes.Buffer) + pem.Encode(certPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certDERBytes, + }) + + certPrivKeyPEM := new(bytes.Buffer) + certPrivKeyByes, err := x509.MarshalPKCS8PrivateKey(certPrivKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal private key to PKCS8: %w", err) + } + + pem.Encode(certPrivKeyPEM, &pem.Block{ + Type: "PRIVATE KEY", + Bytes: certPrivKeyByes, + }) + + certPEMPair.CertificatePEM = certPEM.String() + certPEMPair.PrivateKeyPEM = certPrivKeyPEM.String() + + return certPEMPair, nil +} + +// GenerateClientCertificate creates and signs a certificate based on the provided RootCA. This differs +// from the Server certificate in that it includes the OID for LDAP UID as well as Client Auth key usage. +// +// Currently the key is an RSA key, which also entails adding KeyEncipherment key usage. +func GenerateClientCertificate(intermediate *CertificatePEMPair) (*CertificatePEMPair, error) { + + intermediateKeyPair, err := ParseCertificateKeyPair(intermediate) + if err != nil { + log.Fatalln("Failed to parse root cert and key") + } + + now := time.Now() + + serialNumber, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + + // LDAP OID reference: https://ldap.com/ldap-oid-reference-guide/ + // 0.9.2342.19200300.100.1.1 uid Attribute Type + uidPkix := pkix.AttributeTypeAndValue{ + Type: asn1.ObjectIdentifier([]int{0, 9, 2342, 19200300, 100, 1, 1}), + Value: uid, + } + + cert := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + OrganizationalUnit: []string{"ATC"}, + Organization: []string{"Apache"}, + Country: []string{"US"}, + Province: []string{"Colorado"}, + Locality: []string{"Denver"}, + CommonName: clientCN, + ExtraNames: []pkix.AttributeTypeAndValue{uidPkix}, + }, + NotBefore: now, + NotAfter: now.AddDate(1, 0, 0), + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement, + } + + certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + + certDERBytes, err := x509.CreateCertificate(rand.Reader, cert, intermediateKeyPair.Certificate, &certPrivKey.PublicKey, intermediateKeyPair.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %w", err) + } + + certPEMPair := new(CertificatePEMPair) + + certPEM := new(bytes.Buffer) + pem.Encode(certPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certDERBytes, + }) + + certPrivKeyPEM := new(bytes.Buffer) + certPrivKeyByes, err := x509.MarshalPKCS8PrivateKey(certPrivKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal private key to PKCS8: %w", err) + } + + pem.Encode(certPrivKeyPEM, &pem.Block{ + Type: "PRIVATE KEY", + Bytes: certPrivKeyByes, + }) + + certPEMPair.CertificatePEM = certPEM.String() + certPEMPair.PrivateKeyPEM = certPrivKeyPEM.String() + + return certPEMPair, nil +} + +// GenerateServerCertificate creates and signs a certificate based on the provided RootCA. This differs +// from the Client certificate in that it ServerAuth key usage. It also does NOT include the OID for LDAP UID. +// +// Currently the key is an RSA key, which also entails adding KeyEncipherment key usage. +func GenerateServerCertificate(intermediate *CertificatePEMPair) (*CertificatePEMPair, error) { + + intermediateKeyPair, err := ParseCertificateKeyPair(intermediate) + if err != nil { + log.Fatalln("Failed to parse root cert and key") + } + + now := time.Now() + + serialNumber, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + + cert := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + OrganizationalUnit: []string{"ATC"}, + Organization: []string{"Apache"}, + Country: []string{"US"}, + Province: []string{"Colorado"}, + Locality: []string{"Denver"}, + CommonName: serverCN, + }, + DNSNames: []string{serverCN}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + NotBefore: now, + NotAfter: now.AddDate(1, 0, 0), + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageKeyAgreement, + } + + certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + + certDERBytes, err := x509.CreateCertificate(rand.Reader, cert, intermediateKeyPair.Certificate, &certPrivKey.PublicKey, intermediateKeyPair.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to create certificate: %w", err) + } + + certPEMPair := new(CertificatePEMPair) + + certPEM := new(bytes.Buffer) + pem.Encode(certPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certDERBytes, + }) + + certPrivKeyPEM := new(bytes.Buffer) + certPrivKeyByes, err := x509.MarshalPKCS8PrivateKey(certPrivKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal private key to PKCS8: %w", err) + } + + pem.Encode(certPrivKeyPEM, &pem.Block{ + Type: "PRIVATE KEY", + Bytes: certPrivKeyByes, + }) + + certPEMPair.CertificatePEM = certPEM.String() + certPEMPair.PrivateKeyPEM = certPrivKeyPEM.String() + + return certPEMPair, nil +} + +// VerifyCertificates checks that the client and server certificates match the +// Root and Intermediate chains. +func VerifyCertificates(root, intermediate, client, server *CertificatePEMPair) error { + + rootKeyPair, err := ParseCertificateKeyPair(root) + if err != nil { + log.Fatalln("Failed to parse root cert and key") + } + intermediateKeyPair, err := ParseCertificateKeyPair(intermediate) + if err != nil { + log.Fatalln("Failed to parse intermediate cert and key") + } + + rootPool := x509.NewCertPool() + rootPool.AddCert(rootKeyPair.Certificate) + intermediatePool := x509.NewCertPool() + intermediatePool.AddCert(intermediateKeyPair.Certificate) + + opts := x509.VerifyOptions{ + Intermediates: intermediatePool, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + Roots: rootPool, + } + + clientCert, err := ParseCertificateKeyPair(client) + if err != nil { + return fmt.Errorf("failed to parse client cert and key: %w", err) + } + + if _, err := clientCert.Certificate.Verify(opts); err != nil { + return fmt.Errorf("failed to verify client cert and key: %w", err) + } + + serverCert, err := ParseCertificateKeyPair(server) + if err != nil { + return fmt.Errorf("failed to parse server cert and key: %w", err) + } + + if _, err := serverCert.Certificate.Verify(opts); err != nil { + return fmt.Errorf("failed to verify client cert and key: %w", err) + } + + return nil +} diff --git a/experimental/certificate_auth/example/client/client.go b/experimental/certificate_auth/example/client/client.go new file mode 100644 index 0000000000..c0b197691e --- /dev/null +++ b/experimental/certificate_auth/example/client/client.go @@ -0,0 +1,79 @@ +package main + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import ( + "bytes" + "crypto/tls" + "fmt" + "io/ioutil" + "log" + "net/http" + "time" +) + +func main() { + // LoadX509KeyPair can also load certificate chain with intermediates + cert, _ := tls.LoadX509KeyPair("../certs/client-intermediate-chain.crt.pem", "../certs/client.key.pem") + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + }, + } + + client := http.Client{ + Timeout: time.Second * 60, + Transport: transport, + } + + // Send standard username/password form combo + // reqBody, err := json.Marshal(map[string]string{ + // "u": "userid", + // "p": "exampleuseridpassword", + // }) + // if err != nil { + // log.Fatalln(err) + // } + + req, err := http.NewRequest( + http.MethodPost, + "https://server.local:8443/api/4.0/user/login", + bytes.NewBufferString(""), + // bytes.NewBuffer(reqBody), // username/password + ) + if err != nil { + log.Fatalln(err) + } + + resp, err := client.Do(req) + if err != nil { + log.Fatalln(err) + } + defer resp.Body.Close() + + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + log.Fatalln(err) + } + + fmt.Println(string(respBody)) // Verify Success + fmt.Println(resp.Cookies()) // Verify Cookie(s) +} diff --git a/experimental/certificate_auth/example/server/server.go b/experimental/certificate_auth/example/server/server.go new file mode 100644 index 0000000000..a5608ff262 --- /dev/null +++ b/experimental/certificate_auth/example/server/server.go @@ -0,0 +1,56 @@ +package main + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import ( + "crypto/tls" + "fmt" + "log" + "net/http" +) + +func main() { + handler := http.NewServeMux() + handler.HandleFunc("/", HelloHandler) + + tlsConfig := &tls.Config{ + ClientAuth: tls.RequestClientCert, + } + + server := http.Server{ + Addr: "server.local:8443", + Handler: handler, + TLSConfig: tlsConfig, + } + + if err := server.ListenAndServeTLS("../certs/server.crt.pem", "../certs/server.key.pem"); err != nil { + log.Fatalf("error listening to port: %v", err) + } +} + +func HelloHandler(w http.ResponseWriter, r *http.Request) { + + if r.TLS.PeerCertificates != nil { + clientCert := r.TLS.PeerCertificates[0] + fmt.Println("Client cert subject: ", clientCert.Subject) + } + + fmt.Println("Hello") +} diff --git a/traffic_ops/app/conf/cdn.conf b/traffic_ops/app/conf/cdn.conf index 9eceeefb35..dc5da17cd6 100644 --- a/traffic_ops/app/conf/cdn.conf +++ b/traffic_ops/app/conf/cdn.conf @@ -40,7 +40,8 @@ "profiling_enabled": false, "supported_ds_metrics": [ "kbps", "tps_total", "tps_2xx", "tps_3xx", "tps_4xx", "tps_5xx" ], "tls_config": { - "MinVersion": 769 + "MinVersion": 769, + "ClientAuth": 1 } }, "disable_auto_cert_deletion": false, @@ -102,5 +103,8 @@ }, "cdni" : { "dcdn_id" : "" - } + }, + "client_certificate_authentication" : { + "root_certificates_directory" : "/etc/pki/tls/certs/" + } } diff --git a/traffic_ops/app/conf/production/database.conf b/traffic_ops/app/conf/production/database.conf index 84284caafe..5760268309 100644 --- a/traffic_ops/app/conf/production/database.conf +++ b/traffic_ops/app/conf/production/database.conf @@ -1,4 +1,3 @@ - { "description": "Local PostgreSQL database on port 5432", "dbname": "traffic_ops", diff --git a/traffic_ops/traffic_ops_golang/auth/authorize.go b/traffic_ops/traffic_ops_golang/auth/authorize.go index d1913d94ce..7a26f131eb 100644 --- a/traffic_ops/traffic_ops_golang/auth/authorize.go +++ b/traffic_ops/traffic_ops_golang/auth/authorize.go @@ -170,18 +170,18 @@ func GetCurrentUser(ctx context.Context) (*CurrentUser, error) { return &CurrentUser{"-", -1, PrivLevelInvalid, TenantIDInvalid, -1, "", []string{}, "", nil}, errors.New("No user found in Context") } -func CheckLocalUserIsAllowed(form PasswordForm, db *sqlx.DB, ctx context.Context) (bool, error, error) { +func CheckLocalUserIsAllowed(username string, db *sqlx.DB, ctx context.Context) (bool, error, error) { if usersCacheIsEnabled() { - u, exists := getUserFromCache(form.Username) + u, exists := getUserFromCache(username) if !exists { - return false, fmt.Errorf("user '%s' not found in cache", form.Username), nil + return false, fmt.Errorf("user '%s' not found in cache", username), nil } allowed := u.RoleName != disallowed return allowed, nil, nil } var roleName string - err := db.GetContext(ctx, &roleName, "SELECT role.name FROM role INNER JOIN tm_user ON tm_user.role = role.id where username=$1", form.Username) + err := db.GetContext(ctx, &roleName, "SELECT role.name FROM role INNER JOIN tm_user ON tm_user.role = role.id where username=$1", username) if err != nil { if err == context.DeadlineExceeded || err == context.Canceled { return false, nil, err diff --git a/traffic_ops/traffic_ops_golang/auth/certificate.go b/traffic_ops/traffic_ops_golang/auth/certificate.go new file mode 100644 index 0000000000..eea3e1eb7e --- /dev/null +++ b/traffic_ops/traffic_ops_golang/auth/certificate.go @@ -0,0 +1,167 @@ +package auth + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "io/fs" + "io/ioutil" + "net/http" + "path/filepath" +) + +// ParseCertificate takes a http.Request, pulls the (optionally) provided client TLS +// certificates and attempts to verify them against the directory of provided Root CA +// certificates. The Root CA certificates can be different than those utilized by the +// http.Server. Returns an error if the verification process fails +func VerifyClientCertificate(r *http.Request, rootCertsDirPath string) error { + // TODO: Parse client headers as alternative to TLS in the request + + if err := loadRootCerts(rootCertsDirPath); err != nil { + return fmt.Errorf("failed to load root certificates") + } + + if err := verifyClientRootChain(r.TLS.PeerCertificates); err != nil { + return fmt.Errorf("failed to verify client to root certificate chain") + } + + return nil +} + +func verifyClientRootChain(clientChain []*x509.Certificate) error { + if len(clientChain) == 0 { + return fmt.Errorf("empty client chain") + } + + if rootPool == nil { + return fmt.Errorf("uninitialized root cert pool") + } + + intermediateCertPool := x509.NewCertPool() + for _, intermediate := range clientChain[1:] { + intermediateCertPool.AddCert(intermediate) + } + + opts := x509.VerifyOptions{ + Intermediates: intermediateCertPool, + Roots: rootPool, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + _, err := clientChain[0].Verify(opts) + if err != nil { + return fmt.Errorf("failed to verify client cert chain. err: %w", err) + } + return nil +} + +// Lazy initialized +var rootPool *x509.CertPool + +func loadRootCerts(dirPath string) error { + // Root cert pool already populated + // TODO: This will prevent rolling cert renewals at runtime and will require a TO restart + // to pick up additional certificates. + if rootPool != nil { + return nil + } + + if dirPath == "" { + return fmt.Errorf("empty path supplied for root cert directory") + } + + err := filepath.WalkDir(dirPath, + // walk function to perform on each file in the supplied + // directory path for root certificiates. + // + // For each file in the directory, first check if it, too, is a dir. If so, + // return the filepath.SkipDir error to allow for it to be skipped without + // stopping the subsequent executions. + // + // If of type File, then load the PEM encoded string from the file and + // attempt to decode the PEM block into an x509 certificate. If successful, + // add that certificate to the Root Cert Pool to be used for verification. + // + // Must be a closure for access to the `dirPath` value + func(path string, file fs.DirEntry, e error) error { + if e != nil { + return e + } + + // Skip logic if root directory + if path == dirPath { + return nil + } + + // Don't traverse nested directories + if file.IsDir() { + return filepath.SkipDir + } + + // Read file + pemBytes, err := ioutil.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to open cert at %s. err: %w", path, err) + } + pemBlock, _ := pem.Decode(pemBytes) + // Failed to decode PEM, skip file + if pemBlock == nil { + return nil + } + certificate, err := x509.ParseCertificate(pemBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse PEM into x509. err: %w", err) + } + + if rootPool == nil { + rootPool = x509.NewCertPool() + } + rootPool.AddCert(certificate) + + return nil + }) + if err != nil { + return fmt.Errorf("failed to load root certs from path %s. err: %w", dirPath, err) + } + + return nil +} + +// ParseClientCertificateUID takes an x509 Certificate and loops through the Names in the +// Subject. If it finds an asn.ObjectIdentifier that matches UID, it returns the +// corresponding value. Otherwise returns empty string. If more than one UID is present, +// the first result found to match is returned (order not guaranteed). +func ParseClientCertificateUID(cert *x509.Certificate) string { + + // Object Identifier value for UID used within LDAP + // LDAP OID reference: https://ldap.com/ldap-oid-reference-guide/ + // 0.9.2342.19200300.100.1.1 uid Attribute Type + // asn1.ObjectIdentifier([]int{0, 9, 2342, 19200300, 100, 1, 1}) + + for _, name := range cert.Subject.Names { + t := name.Type + if len(t) == 7 && t[0] == 0 && t[1] == 9 && t[2] == 2342 && t[3] == 19200300 && t[4] == 100 && t[5] == 1 && t[6] == 1 { + return name.Value.(string) + } + } + + return "" +} diff --git a/traffic_ops/traffic_ops_golang/auth/certificate_test.go b/traffic_ops/traffic_ops_golang/auth/certificate_test.go new file mode 100644 index 0000000000..34241f39ff --- /dev/null +++ b/traffic_ops/traffic_ops_golang/auth/certificate_test.go @@ -0,0 +1,367 @@ +package auth + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "net/http" + "testing" +) + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// TODO: Utilize expirimental/certificate_auth/generate_cert.go to create appropriate +// certs on demand for testing, such as expired Before/After dates + +func TestVerifyClientCertificate_Success(t *testing.T) { + rootPool = nil // ensure root pool is empty + + rootCertPEMBlock, _ := pem.Decode([]byte(rootCertPEM)) + rootCert, err := x509.ParseCertificate(rootCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for rootCert. err: %s", err) + } + + rootPool = x509.NewCertPool() + rootPool.AddCert(rootCert) + + req, err := http.NewRequest("POST", "/login", bytes.NewBuffer([]byte{})) + if err != nil { + t.Fatal("failed to create request") + } + + clientCertPEMBlock, _ := pem.Decode([]byte(clientCertPEM)) + clientCert, err := x509.ParseCertificate(clientCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for clientCert. err: %s", err) + } + intermediateCertPEMBlock, _ := pem.Decode([]byte(intermediateCertPEM)) + intermediateCert, err := x509.ParseCertificate(intermediateCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for intermediateCert. err: %s", err) + } + connState := new(tls.ConnectionState) + connState.PeerCertificates = append(connState.PeerCertificates, clientCert) + connState.PeerCertificates = append(connState.PeerCertificates, intermediateCert) + req.TLS = connState + + err = VerifyClientCertificate(req, "root/pool/created/above") + if err != nil { + t.Fatalf("error failed to verify client certificate: %s", err) + } +} + +func TestVerifyClientCertificate_NoIntermediate_Fail(t *testing.T) { + rootPool = nil // ensure root pool is empty + + rootCertPEMBlock, _ := pem.Decode([]byte(rootCertPEM)) + rootCert, err := x509.ParseCertificate(rootCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for rootCert. err: %s", err) + } + + rootPool = x509.NewCertPool() + rootPool.AddCert(rootCert) + + req, err := http.NewRequest("POST", "/login", bytes.NewBuffer([]byte{})) + if err != nil { + t.Fatal("failed to create request") + } + + clientCertPEMBlock, _ := pem.Decode([]byte(clientCertPEM)) + clientCert, err := x509.ParseCertificate(clientCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for clientCert. err: %s", err) + } + + connState := new(tls.ConnectionState) + connState.PeerCertificates = append(connState.PeerCertificates, clientCert) + req.TLS = connState + + err = VerifyClientCertificate(req, "root/pool/created/above") + if err == nil { + t.Fatalf("should have failed without intermediate certificate: %s", err) + } +} + +func TestLoadRootCerts_EmptyDirPath_Fail(t *testing.T) { + rootPool = nil + + err := loadRootCerts("") + + if err == nil { + t.Fatalf("should have failed to load certs with empty path. err: %s", err) + } + +} + +func TestVerifyClientChainSuccess(t *testing.T) { + rootPool = nil // ensure root pool is empty + + rootCertPEMBlock, _ := pem.Decode([]byte(rootCertPEM)) + rootCert, err := x509.ParseCertificate(rootCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for rootCert. err: %s", err) + } + + rootPool = x509.NewCertPool() + rootPool.AddCert(rootCert) + + clientCertPEMBlock, _ := pem.Decode([]byte(clientCertPEM)) + clientCert, err := x509.ParseCertificate(clientCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for clientCert. err: %s", err) + } + intermediateCertPEMBlock, _ := pem.Decode([]byte(intermediateCertPEM)) + intermediateCert, err := x509.ParseCertificate(intermediateCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for intermediateCert. err: %s", err) + } + + if err = verifyClientRootChain([]*x509.Certificate{clientCert, intermediateCert}); err != nil { + t.Fatalf("failed to verify certificate chain with valid certs. err: %s", err) + } +} + +func TestVerifyClientChain_EmptyClient_Fail(t *testing.T) { + rootPool = nil // ensure root pool is empty + + rootCertPEMBlock, _ := pem.Decode([]byte(rootCertPEM)) + rootCert, err := x509.ParseCertificate(rootCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for rootCert. err: %s", err) + } + + rootPool = x509.NewCertPool() + rootPool.AddCert(rootCert) + + if err = verifyClientRootChain([]*x509.Certificate{}); err == nil { + t.Fatalf("failed to verify certificate chain with valid certs. err: %s", err) + } +} + +func TestVerifyClientChain_EmptyRoot_Fail(t *testing.T) { + rootPool = nil // ensure root pool is empty + + clientCertPEMBlock, _ := pem.Decode([]byte(clientCertPEM)) + clientCert, err := x509.ParseCertificate(clientCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for clientCert. err: %s", err) + } + + if err = verifyClientRootChain([]*x509.Certificate{clientCert}); err == nil { + t.Fatalf("failed to verify certificate chain with valid certs. err: %s", err) + } +} + +func TestVerifyClientChain_WrongCertKeyUsage_Fail(t *testing.T) { + rootPool = nil // ensure root pool is empty + + rootCertPEMBlock, _ := pem.Decode([]byte(rootCertPEM)) + rootCert, err := x509.ParseCertificate(rootCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for rootCert. err: %s", err) + } + + rootPool = x509.NewCertPool() + rootPool.AddCert(rootCert) + + // Server cert contains x509.ExtKeyUsageServerAuth (vs ClientAuth) + serverCertPEMBlock, _ := pem.Decode([]byte(serverCertPEM)) + serverCert, err := x509.ParseCertificate(serverCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for serverCert. err: %s", err) + } + + if err = verifyClientRootChain([]*x509.Certificate{serverCert}); err == nil { + t.Fatalf("failed to verify certificate chain with valid certs. err: %s", err) + } +} + +func TestParseClientCertificateUID_Success(t *testing.T) { + rootPool = nil // ensure root pool is empty + + clientCertPEMBlock, _ := pem.Decode([]byte(clientCertPEM)) + clientCert, err := x509.ParseCertificate(clientCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for clientCert. err: %s", err) + } + result := ParseClientCertificateUID(clientCert) + if result != "userid" { + t.Fatal("failed to parse UID value from certificate") + } +} + +func TestParseClientCertificateUID_Fail(t *testing.T) { + rootPool = nil // ensure root pool is empty + + // Server cert does not contain a UID object identifier + serverCertPEMBlock, _ := pem.Decode([]byte(serverCertPEM)) + serverCert, err := x509.ParseCertificate(serverCertPEMBlock.Bytes) + if err != nil { + t.Fatalf("failed to extract x509 from PEM string for serverCert. err: %s", err) + } + result := ParseClientCertificateUID(serverCert) + if len(result) > 0 { + t.Fatal("unexpected UID value from certificate") + } +} + +const rootCertPEM = `-----BEGIN CERTIFICATE----- +MIIFjjCCA3agAwIBAgIIKk/S4uUM2nIwDQYJKoZIhvcNAQELBQAwZTELMAkGA1UE +BhMCVVMxETAPBgNVBAgTCENvbG9yYWRvMQ8wDQYDVQQHEwZEZW52ZXIxDzANBgNV +BAoTBkFwYWNoZTEMMAoGA1UECxMDQVRDMRMwEQYDVQQDEwpyb290LmxvY2FsMB4X +DTIyMTAwNDIwNDIxNVoXDTM3MTAwNDIwNDIxNVowZTELMAkGA1UEBhMCVVMxETAP +BgNVBAgTCENvbG9yYWRvMQ8wDQYDVQQHEwZEZW52ZXIxDzANBgNVBAoTBkFwYWNo +ZTEMMAoGA1UECxMDQVRDMRMwEQYDVQQDEwpyb290LmxvY2FsMIICIjANBgkqhkiG +9w0BAQEFAAOCAg8AMIICCgKCAgEAyJtV6lUQ8ecqI9D9HQKnCaD2gjU7CfKRNZe8 +FEHXlA1rQlU+rDpPmafHZMNaXXJusOxIN70nGEnlTn9ZL+8TMCsKyeq5Y6Diqubw +Ws6kgVpsG73T2X2/gdcow3poCcSAOO0JZypVK3vFlVoB/fBdvB2f3CusV2qYmphf +ffUcykKSSWV6lbeAZYwwOwuKy+eWmgedEJQIQqGqfNAal/UEiGeiqvrsfzu/DzBF +0VXcljTJnXLkESgxESIUHwhIDjcM5sFS5NW/Dru4lodfUPDMW8B9qrW7j7ocDWLK +gbw2ct34HKVBwXC7dYosnawZJ9IVeKa+lMQDRGb5N+Rw6j/iX4JOk5m16bqSEJnh +U4vAk502IfXGFULLDCbm0ju84Hul4oq7I6rPrnTinWGMUCkzyKjhs/7aBvfOsmFr +VyGCnaLw+rEdOr8pPWYP5hfBfggjIoFHb25DWTIbJeu2wr0+F33/60w33RnXoKCl +zmR5Bsfxqaayxd8FcisKignaeibOUtcd+I0xunu/VjXzX7MEA8qrEdFKjiAmj+U8 +WiQkf2u3v37vj1mA+qp65qudaAHwvDhJmVdviri1OGBqF3zYM8xrWxXxQpdQFZh4 +63XxipzF0sTAoDgfcvDsCeKwfwXBvisx4dGHa7a72YvQUD8Kts7XHgwfUBrTWc3m +RGTKc08CAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFIcDMB0+S1Glsa3cEWW8sa396avzMA0GCSqGSIb3DQEBCwUAA4IC +AQBDApE0YcWX1MY9MII1ddheaeAuA5DuwPtSpWS2RWu2NEOZxuQZSrlyaJS0J16L +7OKElgI5M1tHFO2/3ogukIZzrawEYCm/70lYpR9IrSwAEtrkE01D258+d2YpUwlV +uHdYV/rr5pMqXh8hL8ZS6m3CPuMz/w0mytgMiAPLCQb6n2EOuJdbp3EC2FNPa6r2 +5w/MZ+Xrih2fYipVt4oNandKsLdnKeJcvr9h17z6A+QQgA6+BjMGp1eLT/oz3vjo ++4i0Jf8LvkyN9JCmG7zBEMnFxHBebgQeY9TfPS/wOYxpD97UrmEWa7xHi3g9xbnr +3LBoi0rrWkXzYq/CpnEWrMVQo4Z3AGm7z5k+d6KwOoyRntWZA94YUTfGz4Jln4cD +4s4soK5hv87LOcithnajbYcujwhm/YPIMQxh3C0Ziu9qWvYNGS9nnoJvITvQZQOc +YD2Htd/PQTRbqoL93Xdv32/f8zAxHru/4xjf/CkBQ63HvQcY/0z7FW91Bulp31qT +B/bQ1a6PAaCkYC0SXCNnnnyUYx4xS0ggSBFk0ruJpo6NC2+8fDiwxGUZ54VhACw3 +ASix7tHQ/yOcqj2gf6NpZpcdQY+WTgpEWr6orGEuNSp+5pEmD2Qi40TD8KccYaMe +upDLy1qU3S7/C6TXdiIXb5ZX12wreew33AKTc/EqNoEZOQ== +-----END CERTIFICATE----- +` + +const intermediateCertPEM = `-----BEGIN CERTIFICATE----- +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 +oXi7IrLNF0+1/RFnYmKY +-----END CERTIFICATE----- +` + +const clientCertPEM = `-----BEGIN CERTIFICATE----- +MIIFtjCCA56gAwIBAgIILNSPo4amjqowDQYJKoZIhvcNAQELBQAwbTELMAkGA1UE +BhMCVVMxETAPBgNVBAgTCENvbG9yYWRvMQ8wDQYDVQQHEwZEZW52ZXIxDzANBgNV +BAoTBkFwYWNoZTEMMAoGA1UECxMDQVRDMRswGQYDVQQDExJpbnRlcm1lZGlhdGUu +bG9jYWwwHhcNMjIxMDA0MjA0MjE4WhcNMjcxMDA0MjA0MjE4WjB/MQswCQYDVQQG +EwJVUzERMA8GA1UECBMIQ29sb3JhZG8xDzANBgNVBAcTBkRlbnZlcjEPMA0GA1UE +ChMGQXBhY2hlMQwwCgYDVQQLEwNBVEMxFTATBgNVBAMTDGNsaWVudC5sb2NhbDEW +MBQGCgmSJomT8ixkAQETBnVzZXJpZDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC +AgoCggIBAKIjAo5kSO+MYvTLc8yLjzNMvTmOIncYFWd6qUFh4io1aTO3CiD+bEWp +3m5Q+8tACFAIBfLLf06cM0PW2bTwJet4Ol+Wp6v5ieKWDGtz5Ae6piGfu+C3TCW5 +mljdEwDVEXMxftufhgfdc4qVj/plg8iuQRyj4AEhmFtXEvOssWixAPoyk5afUBOB +qaJwqwDY2xja6lE9ECvHULJIGviKFHsZ69TzkMzq0af2/dYRrjG+Zj1ACm9WO6y0 +Y/v/ztcnFtA6Z4EEqDfMA7+BJqDdhjzJ7ISymboNLEtq2sYWrJe+5DPsACSpoTly +FPqn3omy4ax8c4lAnF2Ud/KUqouctGzBwP3lz48aW/Nj5anXh6HoI/h1qGXrRRHI +fTAf0rcbSELkIRiDHpCzIMLBjEZefPiBqjukvZHstR34+TwItnCqNhfGl/+dLGbZ +W5xw2JGcOu+ccTNqUI7bs0wxC8zppAEZ5epXmifhMEwsrQPB66vQIR8lWZyyczOm +rllE36gwDoMjcjSJLuOv7iHzmJVK1S0lccZ1gfoCAhV1cK+YN/BE0Qbtq1ehmYV2 +R0B6kIqzlG3L+s7rNWTj814YbPh8WgMlnApHE523OdeTaScw8ukGz/p4apP/VRsX +fuLzUwB3xabIsllFClNfr8MaZgirMzYVDDuvTbzezSorbplU4I5nAgMBAAGjSDBG +MA4GA1UdDwEB/wQEAwIDiDATBgNVHSUEDDAKBggrBgEFBQcDAjAfBgNVHSMEGDAW +gBR1Nbxb5q86K9ipzGyVmAnCR9HwJTANBgkqhkiG9w0BAQsFAAOCAgEAd/kOLQyr +5PNMirK1EfoYnO2lme/QyO44Wr3kZZQ4X6ZsBKYciuC09nVmBc2VDQS/YOWP1vLu +6UbH8pho5xdqoj+KvDsJtRhKeN+0LxHgJ0u6kmq2Fid3GG5kwxeSEz/7LOJd5Qp9 +E0MbRtm1eu19IVl+3XMSyNLvA0vfAAawpFa85E/rDZfUKWa3JgrjweYXCpz8EgmB +mGroZO4uBc40gLcJOcxGqEB0YioQ6WqLDpOGhWZRxQRdzC1kp64fYkiI7wtX7fBp +VnUwJmi1+A3gLvrT67zkauO8W9niLrorvu3naBDgtRoZhxTsRCjx26NV4aQ9Kx1p +4c5H8RfrD5b8vo+QXbodE9Zj2IZfJZew3/xM9W2GQYT/gPk7AInWKnefUbes3WuE +gzdVaS8FpQCbP7+VJNTIutG5AdvZMz66nYtJOMDb1NB8w/oAI9DBKhNyGwL8AFGq +FbYSDEWpwnu/bBq4uXMRyEUVcbPdRRaabRG4XeowVA40d/VeDkbWlihTDEg49dLd +DIkfv7MZSwYYo35pbGyqzpTEotAZCqeXGSVwwMsjNfSwjb7JTohnnL+0aLfgpxH6 +6v5GpzUawbJrwqvj3/BsBjJAa/95Dyh8IaCB/Jk6pgGLP8+s3SPNpE/JVHDDcKtD +83Q6ELF+UUDc7BsykJBtsxddSkFI9u9Hm7s= +-----END CERTIFICATE----- +` + +const serverCertPEM = `-----BEGIN CERTIFICATE----- +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 +qrCR +-----END CERTIFICATE----- +` diff --git a/traffic_ops/traffic_ops_golang/config/config.go b/traffic_ops/traffic_ops_golang/config/config.go index 37cffbe7c2..84eb3c8799 100644 --- a/traffic_ops/traffic_ops_golang/config/config.go +++ b/traffic_ops/traffic_ops_golang/config/config.go @@ -95,6 +95,7 @@ type Config struct { RoleBasedPermissions bool `json:"role_based_permissions"` DefaultCertificateInfo *DefaultCertificateInfo `json:"default_certificate_info"` Cdni *CdniConf `json:"cdni"` + ClientCertAuth *ClientCertAuth `json:"client_certificate_authentication"` } // ConfigHypnotoad carries http setting for hypnotoad (mojolicious) server @@ -274,6 +275,10 @@ type CdniConf struct { DCdnId string `json:"dcdn_id"` } +type ClientCertAuth struct { + RootCertsDir string `json:"root_certificates_directory"` +} + // NewFakeConfig returns a fake Config struct with just enough data to view Routes. func NewFakeConfig() Config { c := Config{} diff --git a/traffic_ops/traffic_ops_golang/config/config_test.go b/traffic_ops/traffic_ops_golang/config/config_test.go index ee01de555b..24043868fd 100644 --- a/traffic_ops/traffic_ops_golang/config/config_test.go +++ b/traffic_ops/traffic_ops_golang/config/config_test.go @@ -142,7 +142,10 @@ const ( "secrets" : [ "mONKEYDOmONKEYSEE." ], - "inactivity_timeout" : 60 + "inactivity_timeout" : 60, + "client_cert_auth" : { + "root_certs_dir" : "/etc/pki/tls/certs/" + } } ` @@ -259,11 +262,11 @@ func TestLoadConfig(t *testing.T) { } if cfg.CertPath != "/etc/pki/tls/certs/localhost.crt" { - t.Error("Expected KeyPath() == /etc/pki/tls/private/localhost.key") + t.Error("expected CertPath() == /etc/pki/tls/private/localhost.crt") } if cfg.KeyPath != "/etc/pki/tls/private/localhost.key" { - t.Error("Expected KeyPath() == /etc/pki/tls/private/localhost.key") + t.Error("expected KeyPath() == /etc/pki/tls/private/localhost.key") } } diff --git a/traffic_ops/traffic_ops_golang/login/login.go b/traffic_ops/traffic_ops_golang/login/login.go index b1700449ea..f193e36c24 100644 --- a/traffic_ops/traffic_ops_golang/login/login.go +++ b/traffic_ops/traffic_ops_golang/login/login.go @@ -108,135 +108,184 @@ Subject: {{.InstanceName}} Password Reset Request` + "\r\n\r" + ` `)) +// LoginHandler first attempts to verify and parse user information from an optionally +// provided client TLS certificate. If it fails at any point, it will fall back and +// continue with the standard submitted form authentication. func LoginHandler(db *sqlx.DB, cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() authenticated := false form := auth.PasswordForm{} - if err := json.NewDecoder(r.Body).Decode(&form); err != nil { - api.HandleErr(w, r, nil, http.StatusBadRequest, err, nil) - return - } - if form.Username == "" || form.Password == "" { - api.HandleErr(w, r, nil, http.StatusBadRequest, errors.New("username and password are required"), nil) - return - } - resp := struct { - tc.Alerts - }{} + var resp tc.Alerts dbCtx, cancelTx := context.WithTimeout(r.Context(), time.Duration(cfg.DBQueryTimeoutSeconds)*time.Second) defer cancelTx() - userAllowed, err, blockingErr := auth.CheckLocalUserIsAllowed(form, db, dbCtx) - if blockingErr != nil { - api.HandleErr(w, r, nil, http.StatusServiceUnavailable, nil, fmt.Errorf("error checking local user password: %s\n", blockingErr.Error())) - return - } - if err != nil { - log.Errorf("checking local user: %s\n", err.Error()) + + // Attempt to perform client certificate authentication. If fails, goto standard form auth. If the + // certificate was verified, has a UID, and the UID matches an existing user we consider this to + // be a successful login. + { + // No certs provided by the client. Skip to form authentication + if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { + goto FormAuth + } + + // If no configuration is set, skip to form auth + if cfg.ClientCertAuth == nil || len(cfg.ClientCertAuth.RootCertsDir) == 0 { + goto FormAuth + } + + // Perform certificate verification to ensure it is valid against Root CAs + err := auth.VerifyClientCertificate(r, cfg.ClientCertAuth.RootCertsDir) + if err != nil { + log.Warnf("ClientCertAuth: error attempting to verify client provided TLS certificate. err: %s\n", err) + goto FormAuth + } + + // Client provided a verified certificate. Extract UID value. + form.Username = auth.ParseClientCertificateUID(r.TLS.PeerCertificates[0]) + if len(form.Username) == 0 { + log.Infoln("ClientCertAuth: client provided certificate did not contain a UID object identifier or value") + goto FormAuth + } + + // Check if user exists locally (TODB) and has a role. + var blockingErr error + authenticated, err, blockingErr = auth.CheckLocalUserIsAllowed(form.Username, db, dbCtx) + if blockingErr != nil { + api.HandleErr(w, r, nil, http.StatusServiceUnavailable, nil, fmt.Errorf("error checking local user has role: %s", blockingErr.Error())) + return + } + if err != nil { + log.Warnf("ClientCertAuth: checking local user: %s\n", err.Error()) + } + + // Check LDAP if enabled + if !authenticated && cfg.LDAPEnabled { + _, authenticated, err = auth.LookupUserDN(form.Username, cfg.ConfigLDAP) + if err != nil { + log.Warnf("ClientCertAuth: checking ldap user: %s\n", err.Error()) + } + } } - if userAllowed { + + FormAuth: + // Failed certificate-based auth, perform standard form auth + if !authenticated { + // Perform form authentication + if err := json.NewDecoder(r.Body).Decode(&form); err != nil { + api.HandleErr(w, r, nil, http.StatusBadRequest, err, nil) + return + } + if form.Username == "" || form.Password == "" { + api.HandleErr(w, r, nil, http.StatusBadRequest, errors.New("username and password are required"), nil) + return + } + + // Check if user exists and has a role + userAllowed, err, blockingErr := auth.CheckLocalUserIsAllowed(form.Username, db, dbCtx) + if blockingErr != nil { + api.HandleErr(w, r, nil, http.StatusServiceUnavailable, nil, fmt.Errorf("error checking local user has role: %s", blockingErr.Error())) + return + } + if err != nil { + log.Errorf("checking local user: %s\n", err.Error()) + } + + // User w/ role does not exist, return unauthorized + if !userAllowed { + resp = tc.CreateAlerts(tc.ErrorLevel, "Invalid username or password.") + w.WriteHeader(http.StatusUnauthorized) + api.WriteRespRaw(w, r, resp) + return + } + + // Check local DB or LDAP authenticated, err, blockingErr = auth.CheckLocalUserPassword(form, db, dbCtx) if blockingErr != nil { - api.HandleErr(w, r, nil, http.StatusServiceUnavailable, nil, fmt.Errorf("error checking local user password: %s\n", blockingErr.Error())) + api.HandleErr(w, r, nil, http.StatusServiceUnavailable, nil, fmt.Errorf("error checking local user password: %s", blockingErr.Error())) return } if err != nil { log.Errorf("checking local user password: %s\n", err.Error()) } var ldapErr error - if !authenticated { - if cfg.LDAPEnabled { - authenticated, ldapErr = auth.CheckLDAPUser(form, cfg.ConfigLDAP) - if ldapErr != nil { - log.Errorf("checking ldap user: %s\n", ldapErr.Error()) - } + if !authenticated && cfg.LDAPEnabled { + authenticated, ldapErr = auth.CheckLDAPUser(form, cfg.ConfigLDAP) + if ldapErr != nil { + log.Errorf("checking ldap user: %s\n", ldapErr.Error()) } } - if authenticated { - httpCookie := tocookie.GetCookie(form.Username, defaultCookieDuration, cfg.Secrets[0]) - http.SetCookie(w, httpCookie) - - var jwtToken jwt.Token - var jwtSigned []byte - jwtBuilder := jwt.NewBuilder() - - emptyConf := config.CdniConf{} - if cfg.Cdni != nil && *cfg.Cdni != emptyConf { - ucdn, err := auth.GetUserUcdn(form, db, dbCtx) - if err != nil { - // log but do not error out since this is optional in the JWT for CDNi integration - log.Errorf("getting ucdn for user %s: %v", form.Username, err) - } - jwtBuilder.Claim("iss", ucdn) - jwtBuilder.Claim("aud", cfg.Cdni.DCdnId) - } + } - jwtBuilder.Claim("exp", httpCookie.Expires.Unix()) - jwtBuilder.Claim(api.MojoCookie, httpCookie.Value) - jwtToken, err = jwtBuilder.Build() - if err != nil { - api.HandleErr(w, r, nil, http.StatusInternalServerError, nil, fmt.Errorf("building token: %s", err)) - return - } + // Failed to authenticate in either local DB or LDAP, return unauthorized + if !authenticated { + resp = tc.CreateAlerts(tc.ErrorLevel, "Invalid username or password.") + w.WriteHeader(http.StatusUnauthorized) + api.WriteRespRaw(w, r, resp) + return + } - jwtSigned, err = jwt.Sign(jwtToken, jwa.HS256, []byte(cfg.Secrets[0])) - if err != nil { - api.HandleErr(w, r, nil, http.StatusInternalServerError, nil, err) - return - } + // Successful authentication, write cookie and return + httpCookie := tocookie.GetCookie(form.Username, defaultCookieDuration, cfg.Secrets[0]) + http.SetCookie(w, httpCookie) - http.SetCookie(w, &http.Cookie{ - Name: api.AccessToken, - Value: string(jwtSigned), - Path: "/", - MaxAge: httpCookie.MaxAge, - Expires: httpCookie.Expires, - HttpOnly: true, // prevents the cookie being accessed by Javascript. DO NOT remove, security vulnerability - }) - - // If all's well until here, then update last authenticated time - tx, txErr := db.BeginTx(dbCtx, nil) - if txErr != nil { - api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("beginning transaction: %w", txErr)) - return - } - defer func() { - if err := tx.Commit(); err != nil && err != sql.ErrTxDone { - log.Errorln("committing transaction: " + err.Error()) - } - }() - _, dbErr := tx.Exec(UpdateLoginTimeQuery, form.Username) - if dbErr != nil { - log.Errorf("unable to update authentication time for a given user: %s\n", dbErr.Error()) - resp = struct { - tc.Alerts - }{tc.CreateAlerts(tc.ErrorLevel, "Unable to update authentication time for a given user")} - } else { - resp = struct { - tc.Alerts - }{tc.CreateAlerts(tc.SuccessLevel, "Successfully logged in.")} - } + var jwtToken jwt.Token + var jwtSigned []byte + jwtBuilder := jwt.NewBuilder() - } else { - resp = struct { - tc.Alerts - }{tc.CreateAlerts(tc.ErrorLevel, "Invalid username or password.")} + emptyConf := config.CdniConf{} + if cfg.Cdni != nil && *cfg.Cdni != emptyConf { + ucdn, err := auth.GetUserUcdn(form, db, dbCtx) + if err != nil { + // log but do not error out since this is optional in the JWT for CDNi integration + log.Errorf("getting ucdn for user %s: %v", form.Username, err) } - } else { - resp = struct { - tc.Alerts - }{tc.CreateAlerts(tc.ErrorLevel, "Invalid username or password.")} + jwtBuilder.Claim("iss", ucdn) + jwtBuilder.Claim("aud", cfg.Cdni.DCdnId) } - respBts, err := json.Marshal(resp) + + jwtBuilder.Claim("exp", httpCookie.Expires.Unix()) + jwtBuilder.Claim(api.MojoCookie, httpCookie.Value) + jwtToken, err := jwtBuilder.Build() + if err != nil { + api.HandleErr(w, r, nil, http.StatusInternalServerError, nil, fmt.Errorf("building token: %s", err)) + return + } + + jwtSigned, err = jwt.Sign(jwtToken, jwa.HS256, []byte(cfg.Secrets[0])) if err != nil { api.HandleErr(w, r, nil, http.StatusInternalServerError, nil, err) return } - w.Header().Set(rfc.ContentType, rfc.ApplicationJSON) - if !authenticated { - w.WriteHeader(http.StatusUnauthorized) + + http.SetCookie(w, &http.Cookie{ + Name: api.AccessToken, + Value: string(jwtSigned), + Path: "/", + MaxAge: httpCookie.MaxAge, + Expires: httpCookie.Expires, + HttpOnly: true, // prevents the cookie being accessed by Javascript. DO NOT remove, security vulnerability + }) + + // If all's well until here, then update last authenticated time + tx, txErr := db.BeginTx(dbCtx, nil) + if txErr != nil { + api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("beginning transaction: %w", txErr)) + return } - fmt.Fprintf(w, "%s", respBts) + defer func() { + if err := tx.Commit(); err != nil && err != sql.ErrTxDone { + log.Errorln("committing transaction: " + err.Error()) + } + }() + _, dbErr := tx.Exec(UpdateLoginTimeQuery, form.Username) + if dbErr != nil { + log.Errorf("unable to update authentication time for a given user: %s\n", dbErr.Error()) + } + + resp = tc.CreateAlerts(tc.SuccessLevel, "Successfully logged in.") + w.WriteHeader(http.StatusOK) + api.WriteRespRaw(w, r, resp) } } @@ -445,7 +494,7 @@ func OauthLoginHandler(db *sqlx.DB, cfg config.Config) http.HandlerFunc { dbCtx, cancelTx := context.WithTimeout(r.Context(), time.Duration(cfg.DBQueryTimeoutSeconds)*time.Second) defer cancelTx() - userAllowed, err, blockingErr := auth.CheckLocalUserIsAllowed(form, db, dbCtx) + userAllowed, err, blockingErr := auth.CheckLocalUserIsAllowed(form.Username, db, dbCtx) if blockingErr != nil { api.HandleErr(w, r, nil, http.StatusServiceUnavailable, nil, fmt.Errorf("error checking local user password: %s\n", blockingErr.Error())) return diff --git a/traffic_ops/traffic_ops_golang/traffic_ops_golang.go b/traffic_ops/traffic_ops_golang/traffic_ops_golang.go index 60b9b4c2ad..154cf7eef3 100644 --- a/traffic_ops/traffic_ops_golang/traffic_ops_golang.go +++ b/traffic_ops/traffic_ops_golang/traffic_ops_golang.go @@ -198,7 +198,9 @@ func main() { ErrorLog: log.Error, } if httpServer.TLSConfig == nil { - httpServer.TLSConfig = &tls.Config{} + httpServer.TLSConfig = &tls.Config{ + ClientAuth: tls.RequestClientCert, + } } // Deprecated in 5.0 httpServer.TLSConfig.InsecureSkipVerify = cfg.Insecure