From 86bdded8bafcd16df7afa7708e76cbed87184dd1 Mon Sep 17 00:00:00 2001 From: Mark H <06.swivel-robots@icloud.com> Date: Sat, 23 Sep 2023 18:01:31 +0800 Subject: [PATCH] [feat] Add `mtls` authentication for client certificate auth (#615) * Persist auth type in config file * Update `jira init` to configure `mtls` * Update README with instructions --- README.md | 8 ++- api/client.go | 12 ++++ internal/cmd/root/root.go | 6 +- internal/config/generator.go | 116 +++++++++++++++++++++++++++++++++++ pkg/jira/client.go | 57 ++++++++++++++--- pkg/jira/types.go | 2 + 6 files changed, 188 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 91fe9bd7..8d5a03c1 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Follow the [installation guide](https://github.com/ankitpokhrel/jira-cli/wiki/In more [here](https://github.com/ankitpokhrel/jira-cli/discussions/356). 2. Run `jira init`, select installation type as `Local`, and provide the required details to generate a config file required for the tool. + - If you want to use `mtls` (client certificates), select auth type `mtls` and provide the CA Cert, client Key, and client cert. **Note:** If your on-premise Jira installation is using a language other than `English`, then the issue/epic creation may not work because the older version of Jira API doesn't return the untranslated name for `issuetypes`. In that case, @@ -95,8 +96,11 @@ See [FAQs](https://github.com/ankitpokhrel/jira-cli/discussions/categories/faqs) #### Authentication types -The tool supports `basic` and `bearer` (Personal Access Token) authentication types at the moment. Basic auth is used by -default. If you want to use PAT, you need to set `JIRA_AUTH_TYPE` as `bearer`. +The tool supports `basic`, `bearer` (Personal Access Token), and `mtls` (Client Certificates) authentication types. Basic auth is used by +default. + +* If you want to use PAT, you need to set `JIRA_AUTH_TYPE` as `bearer`. +* If you want to use `mtls` run `jira init`. Select installation type `Local`, and then select authentication type as `mtls`. #### Shell completion Check `jira completion --help` for more info on setting up a bash/zsh shell completion. diff --git a/api/client.go b/api/client.go index 4682653a..3490b120 100644 --- a/api/client.go +++ b/api/client.go @@ -48,6 +48,18 @@ func Client(config jira.Config) *jira.Client { config.Insecure = &insecure } + // MTLS + + if config.MTLSConfig.CaCert == "" { + config.MTLSConfig.CaCert = viper.GetString("mtls.ca_cert") + } + if config.MTLSConfig.ClientCert == "" { + config.MTLSConfig.ClientCert = viper.GetString("mtls.client_cert") + } + if config.MTLSConfig.ClientKey == "" { + config.MTLSConfig.ClientKey = viper.GetString("mtls.client_key") + } + jiraClient = jira.NewClient( config, jira.WithTimeout(clientTimeout), diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 24c992c4..3c82787e 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -21,6 +21,7 @@ import ( "github.com/ankitpokhrel/jira-cli/internal/cmd/version" "github.com/ankitpokhrel/jira-cli/internal/cmdutil" jiraConfig "github.com/ankitpokhrel/jira-cli/internal/config" + "github.com/ankitpokhrel/jira-cli/pkg/jira" "github.com/ankitpokhrel/jira-cli/pkg/netrc" "github.com/zalando/go-keyring" @@ -76,7 +77,10 @@ func NewCmdRoot() *cobra.Command { return } - checkForJiraToken(viper.GetString("server"), viper.GetString("login")) + // mtls doesn't need Jira API Token + if viper.GetString("auth_type") != string(jira.AuthTypeMTLS) { + checkForJiraToken(viper.GetString("server"), viper.GetString("login")) + } configFile := viper.ConfigFileUsed() if !jiraConfig.Exists(configFile) { diff --git a/internal/config/generator.go b/internal/config/generator.go index 6e0de82e..87bc24d8 100644 --- a/internal/config/generator.go +++ b/internal/config/generator.go @@ -54,15 +54,24 @@ type issueTypeFieldConf struct { } } +// MTLS authtype specific config. +type JiraCLIMTLSConfig struct { + CaCert string + ClientCert string + ClientKey string +} + // JiraCLIConfig is a Jira CLI config. type JiraCLIConfig struct { Installation string Server string + AuthType string Login string Project string Board string Force bool Insecure bool + MTLS JiraCLIMTLSConfig } // JiraCLIConfigGenerator is a Jira CLI config generator. @@ -81,6 +90,9 @@ type JiraCLIConfigGenerator struct { epic *jira.Epic issueTypes []*jira.IssueType customFields []*issueTypeFieldConf + mtls struct { + caCert, clientCert, clientKey string + } } jiraClient *jira.Client projectSuggestions []string @@ -139,9 +151,23 @@ func (c *JiraCLIConfigGenerator) Generate() (string, error) { if err := c.configureInstallationType(); err != nil { return "", err } + + if c.value.installation == jira.InstallationTypeLocal { + if err := c.configureLocalAuthType(); err != nil { + return "", err + } + } + + if c.value.authType == jira.AuthTypeMTLS { + if err := c.configureMTLS(); err != nil { + return "", err + } + } + if err := c.configureServerAndLoginDetails(); err != nil { return "", err } + if c.value.installation == jira.InstallationTypeLocal { if err := c.configureServerMeta(c.value.server, c.value.login); err != nil { return "", err @@ -190,6 +216,80 @@ func (c *JiraCLIConfigGenerator) configureInstallationType() error { return nil } +func (c *JiraCLIConfigGenerator) configureLocalAuthType() error { + var authType string + + if c.usrCfg.AuthType == "" { + qs := &survey.Select{ + Message: "Authentication type:", + Help: "basic (login) or mtls (client certs)?", + Options: []string{"basic", "mtls"}, + Default: "basic", + } + + if err := survey.AskOne(qs, &authType); err != nil { + return err + } + } + + if authType == strings.ToLower(jira.AuthTypeMTLS.String()) { + c.value.authType = jira.AuthTypeMTLS + } else { + c.value.authType = jira.AuthTypeBasic + } + + return nil +} + +func (c *JiraCLIConfigGenerator) configureMTLS() error { + var qs []*survey.Question + + c.value.mtls.caCert = c.usrCfg.MTLS.CaCert + c.value.mtls.clientCert = c.usrCfg.MTLS.ClientCert + c.value.mtls.clientKey = c.usrCfg.MTLS.ClientKey + + getIfEmpty := func(conf, name, msg, help string) { + if conf != "" { + return + } + qs = append(qs, &survey.Question{ + Name: name, + Prompt: &survey.Input{ + Message: msg, + Help: help, + }, + }) + } + + getIfEmpty(c.value.mtls.caCert, "cacert", "CA Certificate", "Local path to CA Certificate for your `server`") + getIfEmpty(c.value.mtls.clientCert, "clientcert", "Client Certificate", "Local path to your client certificate") + getIfEmpty(c.value.mtls.clientKey, "clientkey", "Client Key", "Local path to your client key") + + if len(qs) > 0 { + ans := struct { + CaCert string + ClientCert string + ClientKey string + }{} + + if err := survey.Ask(qs, &ans); err != nil { + return err + } + + if ans.CaCert != "" { + c.value.mtls.caCert = ans.CaCert + } + if ans.ClientCert != "" { + c.value.mtls.clientCert = ans.ClientCert + } + if ans.ClientKey != "" { + c.value.mtls.clientKey = ans.ClientKey + } + } + + return nil +} + //nolint:gocyclo func (c *JiraCLIConfigGenerator) configureServerAndLoginDetails() error { var qs []*survey.Question @@ -312,6 +412,11 @@ func (c *JiraCLIConfigGenerator) verifyLoginDetails(server, login string) error Insecure: &c.usrCfg.Insecure, AuthType: c.value.authType, Debug: viper.GetBool("debug"), + MTLSConfig: jira.MTLSConfig{ + CaCert: c.value.mtls.caCert, + ClientCert: c.value.mtls.clientCert, + ClientKey: c.value.mtls.clientKey, + }, }) if ret, err := c.jiraClient.Me(); err != nil { return err @@ -337,6 +442,11 @@ func (c *JiraCLIConfigGenerator) configureServerMeta(server, login string) error Insecure: &c.usrCfg.Insecure, AuthType: c.value.authType, Debug: viper.GetBool("debug"), + MTLSConfig: jira.MTLSConfig{ + CaCert: c.value.mtls.caCert, + ClientCert: c.value.mtls.clientCert, + ClientKey: c.value.mtls.clientKey, + }, }) info, err := c.jiraClient.ServerInfo() if err != nil { @@ -634,6 +744,12 @@ func (c *JiraCLIConfigGenerator) write(path string) (string, error) { config.Set("epic", c.value.epic) config.Set("issue.types", c.value.issueTypes) config.Set("issue.fields.custom", c.value.customFields) + config.Set("auth_type", c.value.authType) + + // MTLS + config.Set("mtls.ca_cert", c.value.mtls.caCert) + config.Set("mtls.client_cert", c.value.mtls.clientCert) + config.Set("mtls.client_key", c.value.mtls.clientKey) if c.value.version.major > 0 { config.Set("version.major", c.value.version.major) diff --git a/pkg/jira/client.go b/pkg/jira/client.go index 51da1e25..eaa23efb 100644 --- a/pkg/jira/client.go +++ b/pkg/jira/client.go @@ -4,11 +4,14 @@ import ( "bytes" "context" "crypto/tls" + "crypto/x509" "encoding/json" "fmt" + "log" "net" "net/http" "net/http/httputil" + "os" "strings" "time" ) @@ -93,14 +96,22 @@ func (e Errors) String() string { // Header is a key, value pair for request headers. type Header map[string]string +// MTLS authtype specific config. +type MTLSConfig struct { + CaCert string + ClientCert string + ClientKey string +} + // Config is a jira config. type Config struct { - Server string - Login string - APIToken string - AuthType AuthType - Insecure *bool - Debug bool + Server string + Login string + APIToken string + AuthType AuthType + Insecure *bool + Debug bool + MTLSConfig MTLSConfig } // Client is a jira client. @@ -132,14 +143,40 @@ func NewClient(c Config, opts ...ClientFunc) *Client { opt(&client) } - client.transport = &http.Transport{ - Proxy: http.ProxyFromEnvironment, - TLSClientConfig: &tls.Config{InsecureSkipVerify: client.insecure}, + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: client.insecure, + }, DialContext: (&net.Dialer{ Timeout: client.timeout, }).DialContext, } + if c.AuthType == AuthTypeMTLS { + // Create a CA certificate pool and add cert.pem to it + caCert, err := os.ReadFile(c.MTLSConfig.CaCert) + if err != nil { + log.Fatalf("%s, %s", err, c.MTLSConfig.CaCert) + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + // Read the key pair to create the certificate. + cert, err := tls.LoadX509KeyPair(c.MTLSConfig.ClientCert, c.MTLSConfig.ClientKey) + if err != nil { + log.Fatal(err) + } + + // Add the MTLS specific configuration. + transport.TLSClientConfig.RootCAs = caCertPool + transport.TLSClientConfig.Certificates = []tls.Certificate{cert} + transport.TLSClientConfig.Renegotiation = tls.RenegotiateFreelyAsClient + } + + client.transport = transport + return &client } @@ -226,7 +263,7 @@ func (c *Client) request(ctx context.Context, method, endpoint string, body []by if c.authType == AuthTypeBearer { req.Header.Add("Authorization", "Bearer "+c.token) - } else { + } else if c.authType == AuthTypeBasic { req.SetBasicAuth(c.login, c.token) } diff --git a/pkg/jira/types.go b/pkg/jira/types.go index ac785127..56217ce1 100644 --- a/pkg/jira/types.go +++ b/pkg/jira/types.go @@ -9,6 +9,8 @@ const ( AuthTypeBasic AuthType = "basic" // AuthTypeBearer is a bearer auth. AuthTypeBearer AuthType = "bearer" + // AuthTypeMTLS is a mTLS auth. + AuthTypeMTLS AuthType = "mtls" ) // AuthType is a jira authentication type.