diff --git a/lib/client/api.go b/lib/client/api.go index 72523949689c5..53fd66186b0d9 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -237,6 +237,9 @@ type Config struct { // CheckVersions will check that client version is compatible // with auth server version when connecting. CheckVersions bool + + // BindAddr is an optional host:port to bind to for SSO redirect flows + BindAddr string } // CachePolicy defines cache policy for local clients @@ -1799,16 +1802,18 @@ func (tc *TeleportClient) directLogin(ctx context.Context, secondFactorType stri func (tc *TeleportClient) ssoLogin(ctx context.Context, connectorID string, pub []byte, protocol string) (*auth.SSHLoginResponse, error) { log.Debugf("samlLogin start") // ask the CA (via proxy) to sign our public key: - response, err := SSHAgentSSOLogin( - ctx, - tc.Config.WebProxyAddr, - connectorID, - pub, - tc.KeyTTL, - tc.InsecureSkipVerify, - loopbackPool(tc.Config.WebProxyAddr), - protocol, - tc.CertificateFormat) + response, err := SSHAgentSSOLogin(SSHLogin{ + Context: ctx, + ProxyAddr: tc.Config.WebProxyAddr, + ConnectorID: connectorID, + PubKey: pub, + TTL: tc.KeyTTL, + Insecure: tc.InsecureSkipVerify, + Pool: loopbackPool(tc.Config.WebProxyAddr), + Protocol: protocol, + Compatibility: tc.CertificateFormat, + BindAddr: tc.BindAddr, + }) return response, trace.Wrap(err) } diff --git a/lib/client/weblogin.go b/lib/client/weblogin.go index 065002b71df5e..2ebad83b150e9 100644 --- a/lib/client/weblogin.go +++ b/lib/client/weblogin.go @@ -1,5 +1,5 @@ /* -Copyright 2015 Gravitational, Inc. +Copyright 2015-2019 Gravitational, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ import ( "github.com/gravitational/teleport" "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/roundtrip" "github.com/gravitational/trace" @@ -129,9 +130,34 @@ type sealData struct { Nonce []byte `json:"nonce"` } +// SSHLogin contains SSH login parameters +type SSHLogin struct { + // Context is an external context + Context context.Context + // ProxyAddr is the target proxy address + ProxyAddr string + // ConnectorID is the OIDC or SAML connector ID to use + ConnectorID string + // PubKey is SSH public key to sign + PubKey []byte + // TTL is requested TTL of the client certificates + TTL time.Duration + // Insecure turns off verification for x509 target proxy + Insecure bool + // Pool is x509 cert pool to use for server certifcate verification + Pool *x509.CertPool + // Protocol is an optional protocol selection + Protocol string + // Compatibility sets compatibility mode for SSH certificates + Compatibility string + // BindAddr is an optional host:port address to bind + // to for SSO login flows + BindAddr string +} + // SSHAgentSSOLogin is used by SSH Agent (tsh) to login using OpenID connect -func SSHAgentSSOLogin(ctx context.Context, proxyAddr, connectorID string, pubKey []byte, ttl time.Duration, insecure bool, pool *x509.CertPool, protocol string, compatibility string) (*auth.SSHLoginResponse, error) { - clt, proxyURL, err := initClient(proxyAddr, insecure, pool) +func SSHAgentSSOLogin(login SSHLogin) (*auth.SSHLoginResponse, error) { + clt, proxyURL, err := initClient(login.ProxyAddr, login.Insecure, login.Pool) if err != nil { return nil, trace.Wrap(err) } @@ -167,7 +193,7 @@ func SSHAgentSSOLogin(ctx context.Context, proxyAddr, connectorID string, pubKey }) } - server := httptest.NewServer(makeHandler(func(w http.ResponseWriter, r *http.Request) (*auth.SSHLoginResponse, error) { + handler := makeHandler(func(w http.ResponseWriter, r *http.Request) (*auth.SSHLoginResponse, error) { if r.URL.Path != "/callback" { return nil, trace.NotFound("path not found") } @@ -185,10 +211,39 @@ func SSHAgentSSOLogin(ctx context.Context, proxyAddr, connectorID string, pubKey } return re, nil - })) + }) + + redirPath := "/" + uuid.New() + // longURL will be set based on the response from the webserver + var longURL utils.SyncString + mux := http.NewServeMux() + mux.Handle("/callback", handler) + mux.HandleFunc(redirPath, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, longURL.Value(), http.StatusFound) + }) + redir := httptest.NewServer(mux) + defer redir.Close() + + var server *httptest.Server + if login.BindAddr != "" { + log.Debugf("Binding to %v.", login.BindAddr) + listener, err := net.Listen("tcp", login.BindAddr) + if err != nil { + return nil, trace.Wrap(err, "%v: could not bind to %v, make sure the address is host:port format for ipv4 and [ipv6]:port format for ipv6, and the address is not in use", err, login.BindAddr) + } + server = &httptest.Server{ + Listener: listener, + Config: &http.Server{Handler: mux}, + } + server.Start() + } else { + server = httptest.NewServer(mux) + } defer server.Close() - // Encode the secret into "secret_key" parameter. + // redirURL is the short URL presented to the user + redirURL := server.URL + redirPath + u, err := url.Parse(server.URL + "/callback") if err != nil { return nil, trace.Wrap(err) @@ -197,12 +252,12 @@ func SSHAgentSSOLogin(ctx context.Context, proxyAddr, connectorID string, pubKey query.Set("secret_key", key.String()) u.RawQuery = query.Encode() - out, err := clt.PostJSON(ctx, clt.Endpoint("webapi", protocol, "login", "console"), SSOLoginConsoleReq{ + out, err := clt.PostJSON(login.Context, clt.Endpoint("webapi", login.Protocol, "login", "console"), SSOLoginConsoleReq{ RedirectURL: u.String(), - PublicKey: pubKey, - CertTTL: ttl, - ConnectorID: connectorID, - Compatibility: compatibility, + PublicKey: login.PubKey, + CertTTL: login.TTL, + ConnectorID: login.ConnectorID, + Compatibility: login.Compatibility, }) if err != nil { return nil, trace.Wrap(err) @@ -213,18 +268,7 @@ func SSHAgentSSOLogin(ctx context.Context, proxyAddr, connectorID string, pubKey if err != nil { return nil, trace.Wrap(err) } - - // Start a HTTP server on the client that re-directs to the SAML provider. - // This creates nice short URLs and also works around some platforms (like - // Windows) that truncate long URLs before passing them to the default browser. - redirPath := "/" + uuid.New() - redirMux := http.NewServeMux() - redirMux.HandleFunc(redirPath, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, re.RedirectURL, http.StatusFound) - }) - redir := httptest.NewServer(redirMux) - defer redir.Close() - redirURL := redir.URL + redirPath + longURL.Set(re.RedirectURL) // If a command was found to launch the browser, create and start it. var execCmd *exec.Cmd @@ -254,7 +298,7 @@ func SSHAgentSSOLogin(ctx context.Context, proxyAddr, connectorID string, pubKey // Print to screen in-case the command that launches the browser did not run. fmt.Printf("If browser window does not open automatically, open it by ") - fmt.Printf("clicking on the link:\n %v\n", redirURL) + fmt.Printf("clicking on the link:\n %v\n", utils.ClickableURL(redirURL)) log.Infof("Waiting for response at: %v.", server.URL) @@ -268,9 +312,9 @@ func SSHAgentSSOLogin(ctx context.Context, proxyAddr, connectorID string, pubKey case <-time.After(defaults.CallbackTimeout): log.Debugf("Timed out waiting for callback after %v.", defaults.CallbackTimeout) return nil, trace.Wrap(trace.Errorf("timed out waiting for callback")) - case <-ctx.Done(): + case <-login.Context.Done(): log.Debugf("Canceled by user.") - return nil, trace.Wrap(ctx.Err()) + return nil, trace.Wrap(login.Context.Err()) } } diff --git a/lib/utils/utils.go b/lib/utils/utils.go index 2672641d3840f..b3c691ae90d6e 100644 --- a/lib/utils/utils.go +++ b/lib/utils/utils.go @@ -18,14 +18,17 @@ package utils import ( "encoding/json" + "fmt" "io" "io/ioutil" "net" + "net/url" "os" "path/filepath" "runtime" "strconv" "strings" + "sync" "time" "github.com/gravitational/teleport" @@ -67,6 +70,49 @@ func ThisFunction() string { return runtime.FuncForPC(pc[0]).Name() } +// SyncString is a string value +// that can be concurrently accessed +type SyncString struct { + sync.Mutex + string +} + +// Value returns value of the string +func (s *SyncString) Value() string { + s.Lock() + defer s.Unlock() + return s.string +} + +// Set sets the value of the string +func (s *SyncString) Set(v string) { + s.Lock() + defer s.Unlock() + s.string = v +} + +// ClickableURL fixes address in url to make sure +// it's clickable, e.g. it replaces "undefined" address like +// 0.0.0.0 used in network listeners format with loopback 127.0.0.1 +func ClickableURL(in string) string { + out, err := url.Parse(in) + if err != nil { + return in + } + host, port, err := net.SplitHostPort(out.Host) + if err != nil { + return in + } + ip := net.ParseIP(host) + // if address is not an IP, unspecified, e.g. all interfaces 0.0.0.0 or multicast, + // replace with localhost that is clickable + if len(ip) == 0 || ip.IsUnspecified() || ip.IsMulticast() { + out.Host = fmt.Sprintf("127.0.0.1:%v", port) + return out.String() + } + return out.String() +} + // AsBool converts string to bool, in case of the value is empty // or unknown, defaults to false func AsBool(v string) bool { diff --git a/lib/utils/utils_test.go b/lib/utils/utils_test.go index 6692e23ace58f..de43cb3c640c6 100644 --- a/lib/utils/utils_test.go +++ b/lib/utils/utils_test.go @@ -144,6 +144,26 @@ func (s *UtilsSuite) TestVersions(c *check.C) { } } +// TestClickableURL tests clickable URL conversions +func (s *UtilsSuite) TestClickableURL(c *check.C) { + testCases := []struct { + info string + in string + out string + }{ + {info: "original URL is OK", in: "http://127.0.0.1:3000/hello", out: "http://127.0.0.1:3000/hello"}, + {info: "unspecified IPV6", in: "http://[::]:5050/howdy", out: "http://127.0.0.1:5050/howdy"}, + {info: "unspecified IPV4", in: "http://0.0.0.0:5050/howdy", out: "http://127.0.0.1:5050/howdy"}, + {info: "specified IPV4", in: "http://192.168.1.1:5050/howdy", out: "http://192.168.1.1:5050/howdy"}, + {info: "specified IPV6", in: "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:5050/howdy", out: "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:5050/howdy"}, + } + for i, testCase := range testCases { + comment := check.Commentf("test case %v %q", i, testCase.info) + out := ClickableURL(testCase.in) + c.Assert(out, check.Equals, testCase.out, comment) + } +} + // TestParseSessionsURI parses sessions URI func (s *UtilsSuite) TestParseSessionsURI(c *check.C) { testCases := []struct { diff --git a/tool/tsh/tsh.go b/tool/tsh/tsh.go index ff67bb451de34..98bb7e6a4dcec 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -134,6 +134,10 @@ type CLIConf struct { // format to use with --out to store a fershly retreived certificate IdentityFormat client.IdentityFileFormat + // BindAddr is an address in the form of host:port to bind to + // during `tsh login` command + BindAddr string + // AuthConnector is the name of the connector to use. AuthConnector string @@ -166,8 +170,9 @@ func main() { } const ( - clusterEnvVar = "TELEPORT_SITE" - clusterHelp = "Specify the cluster to connect" + clusterEnvVar = "TELEPORT_SITE" + clusterHelp = "Specify the cluster to connect" + bindAddrEnvVar = "TELEPORT_LOGIN_BIND_ADDR" ) // Run executes TSH client. same as main() but easier to test @@ -240,6 +245,7 @@ func Run(args []string, underTest bool) { // login logs in with remote proxy and obtains a "session certificate" which gets // stored in ~/.tsh directory login := app.Command("login", "Log in to a cluster and retrieve the session certificate") + login.Flag("bind-addr", "Address in the form of host:port to bind to for login command webhook").Envar(bindAddrEnvVar).StringVar(&cf.BindAddr) login.Flag("out", "Identity output").Short('o').AllowDuplicate().StringVar(&cf.IdentityFileOut) login.Flag("format", fmt.Sprintf("Identity format [%s] or %s (for OpenSSH compatibility)", client.DefaultIdentityFormat, @@ -917,7 +923,7 @@ func makeClient(cf *CLIConf, useProfileLogin bool) (tc *client.TeleportClient, e if options.StrictHostKeyChecking == false { c.HostKeyCallback = client.InsecureSkipHostKeyChecking } - + c.BindAddr = cf.BindAddr return client.NewClient(c) }