Skip to content

Commit

Permalink
http: add mTLS for client authentication
Browse files Browse the repository at this point in the history
Add configuration field to enable Mutual TLS client authentication.

Signed-off-by: Romain Beuque <[email protected]>
  • Loading branch information
rbeuque74 committed Nov 24, 2020
1 parent 7b37ee7 commit f2cea76
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 20 deletions.
11 changes: 10 additions & 1 deletion pkg/plugins/builtin/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This plugin permorms an HTTP request.
| `body` | a string representing the payload to be sent with the request |
| `headers` | a list of headers, represented as (`name`, `value`) pairs |
| `timeout` | timeout expressed as a duration (e.g. `30s`) |
| `auth` | a single object composed of either a `basic` object with `user` and `password` fields to enable HTTP basic auth, or `bearer` field to enable Bearer Token Authorization |
| `auth` | a single object composed of either a `basic` object with `user` and `password` fields to enable HTTP basic auth, or a `bearer` field to enable Bearer Token Authorization, or a `mutual_tls` object to enable Mutual TLS authentication |
| `follow_redirect` | if `true` (string) the plugin will follow up to 10 redirects (302, ...) |
| `query_parameters` | a list of query parameters, represented as (`name`, `value`) pairs; these will appended the query parameters present in the `url` field; parameters can be repeated (in either `url` or `query_parameters`) which will produce e.g. `?param=value1&param=value2` |
| `trim_prefix` | prefix in the response that must be removed before unmarshalling (optional) |
Expand All @@ -39,8 +39,17 @@ action:
user: {{.config.basicAuth.user}}
password: {{.config.basicAuth.password}}
bearer: {{.config.auth.token}}
mutual_tls:
# a chain of certificates to identify the caller, first certificate in the chain is considered as the leaf, followed by intermediates
client_cert: {{.config.mtls.clientCert}}
# private key corresponding to the certificate
client_key: {{.config.mtls.clientKey}}
# optional, string as boolean
follow_redirect: "true"
# optional, defines additional root CAs to perform the call. can contains multiple CAs concatained together
root_ca: {{.config.mtls.rootca}}
# optional, string as boolean. indicates if server certificate must be validated or not.
insecure_skip_verify: "false"
# optional, array of name and value fields
query_parameters:
- name: foo
Expand Down
70 changes: 54 additions & 16 deletions pkg/plugins/builtin/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"github.com/ovh/utask/pkg/plugins/builtin/httputil"
"github.com/ovh/utask/pkg/plugins/taskplugin"
"github.com/ovh/utask/pkg/utils"
"golang.org/x/net/http2"
)

// the HTTP plugin performs an HTTP call
Expand All @@ -28,15 +27,6 @@ var (
)
)

var defaultUnsecureTransport http.RoundTripper

func init() {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
_ = http2.ConfigureTransport(tr)
defaultUnsecureTransport = tr
}

const (
// TimeoutDefault represents the default value that will be used for HTTP call, if not defined in configuration
TimeoutDefault = "30s"
Expand All @@ -56,6 +46,7 @@ type HTTPConfig struct {
QueryParameters []parameter `json:"query_parameters,omitempty"`
TrimPrefix string `json:"trim_prefix,omitempty"`
InsecureSkipVerify string `json:"insecure_skip_verify,omitempty"`
RootCA string `json:"root_ca,omitempty"`
}

// parameter represents either headers, query parameters, ...
Expand All @@ -66,8 +57,9 @@ type parameter struct {

// auth represents HTTP authentication
type auth struct {
Basic authBasic `json:"basic"`
Bearer string `json:"bearer"`
Basic *authBasic `json:"basic"`
Bearer *string `json:"bearer"`
MutualTLS *mTLS `json:"mutual_tls"`
}

// authBasic represents the embedded basic auth inside Auth struct
Expand All @@ -76,6 +68,11 @@ type authBasic struct {
Password string `json:"password"`
}

type mTLS struct {
ClientCert string `json:"client_cert"`
ClientKey string `json:"client_key"`
}

func validConfig(config interface{}) error {
cfg := config.(*HTTPConfig)
if !strings.HasPrefix(cfg.Method, "{{") && !strings.HasSuffix(cfg.Method, "}}") {
Expand Down Expand Up @@ -110,6 +107,26 @@ func validConfig(config interface{}) error {
}
}

if cfg.Auth.Basic != nil && cfg.Auth.Bearer != nil {
return fmt.Errorf("basic auth and bearer auth are mutually exclusive")
}

if cfg.Auth.Basic != nil {
if cfg.Auth.Basic.User == "" || cfg.Auth.Basic.Password == "" {
return fmt.Errorf("missing either user or password for basic auth")
}
}

if cfg.Auth.Bearer != nil && *cfg.Auth.Bearer == "" {
return fmt.Errorf("missing bearer token value")
}

if cfg.Auth.MutualTLS != nil {
if cfg.Auth.MutualTLS.ClientCert == "" || cfg.Auth.MutualTLS.ClientKey == "" {
return fmt.Errorf("missing either client_cert or client_key for mTLS")
}
}

return nil
}

Expand Down Expand Up @@ -170,10 +187,10 @@ func exec(stepName string, config interface{}, ctx interface{}) (interface{}, in
}
req.URL.RawQuery = q.Encode()

if cfg.Auth.Bearer != "" {
var bearer = "Bearer " + cfg.Auth.Bearer
if cfg.Auth.Bearer != nil {
var bearer = "Bearer " + *cfg.Auth.Bearer
req.Header.Add("Authorization", bearer)
} else if cfg.Auth.Basic.User != "" && cfg.Auth.Basic.Password != "" {
} else if cfg.Auth.Basic != nil {
req.SetBasicAuth(cfg.Auth.Basic.User, cfg.Auth.Basic.Password)
}

Expand Down Expand Up @@ -219,9 +236,30 @@ func exec(stepName string, config interface{}, ctx interface{}) (interface{}, in
Timeout: td,
FollowRedirect: fr,
}
opts := []func(*http.Transport) error{}
if insecureSkipVerify {
httpClientConfig.Transport = defaultUnsecureTransport
opts = append(opts, httputil.WithTLSInsecureSkipVerify(true))
}

if cfg.Auth.MutualTLS != nil {
cert, err := tls.X509KeyPair([]byte(cfg.Auth.MutualTLS.ClientCert), []byte(cfg.Auth.MutualTLS.ClientKey))
if err != nil {
return nil, nil, fmt.Errorf("failed to parse x509 mTLS certificate or key: %s", err)
}
opts = append(opts, httputil.WithTLSClientAuth(cert))
}

if cfg.RootCA != "" {
opts = append(opts, httputil.WithTLSRootCA([]byte(cfg.RootCA)))
}

if len(opts) > 0 {
httpClientConfig.Transport, err = httputil.GetTransport(opts...)
if err != nil {
return nil, nil, fmt.Errorf("failed to craft a new http transport: %s", err)
}
}

httpClient := httputil.NewHTTPClient(httpClientConfig)

resp, err := httpClient.Do(req)
Expand Down
44 changes: 41 additions & 3 deletions pkg/plugins/builtin/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import (
)

func Test_validConfig(t *testing.T) {
bearerToken := "my_token"
cfg := HTTPConfig{
URL: "http://lolcat.host/stuff",
Method: "GET",
Timeout: "10s",
FollowRedirect: "false",
Auth: auth{
Bearer: "my_token",
Basic: authBasic{
Basic: &authBasic{
User: "foo",
Password: "bar",
},
Expand Down Expand Up @@ -74,6 +74,43 @@ func Test_validConfig(t *testing.T) {
},
}

// wrong auth: exclusive auth added
cfg.Auth.Bearer = &bearerToken
cfgJSON, err = json.Marshal(cfg)
assert.NoError(t, err)
assert.Errorf(t, Plugin.ValidConfig(json.RawMessage(""), json.RawMessage(cfgJSON)), "basic auth and bearer auth are mutually exclusive")
cfg.Auth.Bearer = nil

// wrong auth: invalid basic auth
cfg.Auth.Basic.Password = ""
cfgJSON, err = json.Marshal(cfg)
assert.NoError(t, err)
assert.Errorf(t, Plugin.ValidConfig(json.RawMessage(""), json.RawMessage(cfgJSON)), "missing either user or password for basic auth")
cfg.Auth.Basic.Password = "bar"

// wrong auth: invalid bearer auth
cfg.Auth.Basic = nil
empty := ""
cfg.Auth.Bearer = &empty
cfgJSON, err = json.Marshal(cfg)
assert.NoError(t, err)
assert.Errorf(t, Plugin.ValidConfig(json.RawMessage(""), json.RawMessage(cfgJSON)), "missing bearer token value")
cfg.Auth.Basic = &authBasic{
User: "foo",
Password: "bar",
}
cfg.Auth.Bearer = nil

// wrong auth: invalid mTLS auth
cfg.Auth.MutualTLS = &mTLS{
ClientCert: "foo",
ClientKey: "",
}
cfgJSON, err = json.Marshal(cfg)
assert.NoError(t, err)
assert.Errorf(t, Plugin.ValidConfig(json.RawMessage(""), json.RawMessage(cfgJSON)), "missing either client_cert or client_key for mTLS")
cfg.Auth.MutualTLS = nil

// no URL
cfg.URL = ""
cfgJSON, err = json.Marshal(cfg)
Expand Down Expand Up @@ -128,6 +165,7 @@ func Test_exec(t *testing.T) {
}
}

bearerToken := "my_token"
cfg := HTTPConfig{
URL: "http://lolcat.host/stuff",
Method: "GET",
Expand All @@ -140,7 +178,7 @@ func Test_exec(t *testing.T) {
Timeout: "10s",
FollowRedirect: "false",
Auth: auth{
Bearer: "my_token",
Bearer: &bearerToken,
},
}

Expand Down
58 changes: 58 additions & 0 deletions pkg/plugins/builtin/httputil/httputil.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package httputil

import (
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"

"github.com/juju/errors"
"golang.org/x/net/http2"

"github.com/ovh/utask/pkg/plugins/taskplugin"
"github.com/ovh/utask/pkg/utils"
Expand Down Expand Up @@ -110,3 +113,58 @@ func defaultHTTPClientFactory(cfg HTTPClientConfig) HTTPClient {
}
return c
}

func GetTransport(opts ...func(*http.Transport) error) (http.RoundTripper, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
for _, o := range opts {
if err := o(tr); err != nil {
return tr, err
}
}

_ = http2.ConfigureTransport(tr)
return tr, nil
}

func WithTLSInsecureSkipVerify(v bool) func(*http.Transport) error {
return func(t *http.Transport) error {
if t.TLSClientConfig == nil {
t.TLSClientConfig = &tls.Config{}
}

t.TLSClientConfig.InsecureSkipVerify = v
return nil
}
}

func WithTLSClientAuth(cert tls.Certificate) func(*http.Transport) error {
return func(t *http.Transport) error {
if t.TLSClientConfig == nil {
t.TLSClientConfig = &tls.Config{}
}

t.TLSClientConfig.Certificates = append(t.TLSClientConfig.Certificates, cert)
return nil
}
}

// WithTLSRootCA should be called only once, with multiple PEM encoded certificates as input if needed.
func WithTLSRootCA(caCert []byte) func(*http.Transport) error {
return func(t *http.Transport) error {
if t.TLSClientConfig == nil {
t.TLSClientConfig = &tls.Config{}
}
caCertPool, err := x509.SystemCertPool()
if err != nil {
fmt.Println("http: tls: failed to load default system cert pool, fallback to an empty cert pool")
caCertPool = x509.NewCertPool()
}

if ok := caCertPool.AppendCertsFromPEM(caCert); !ok {
return errors.New("WithTLSRootCA: failed to add a certificate to the cert pool")
}

t.TLSClientConfig.RootCAs = caCertPool
return nil
}
}

0 comments on commit f2cea76

Please sign in to comment.