diff --git a/api/types/headlessauthn.go b/api/types/headlessauthn.go index 39ff6a6341984..b49d3d637f248 100644 --- a/api/types/headlessauthn.go +++ b/api/types/headlessauthn.go @@ -45,3 +45,17 @@ func (h *HeadlessAuthentication) CheckAndSetDefaults() error { func (h *HeadlessAuthentication) setStaticFields() { h.Kind = KindHeadlessAuthentication } + +// Stringify returns the readable string for a headless authentication state. +func (h HeadlessAuthenticationState) Stringify() string { + switch h { + case HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_PENDING: + return "pending" + case HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_DENIED: + return "denied" + case HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_APPROVED: + return "approved" + default: + return "unknown" + } +} diff --git a/lib/client/api.go b/lib/client/api.go index 03a1f48052153..0101a1a9e38d3 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -401,6 +401,11 @@ type Config struct { // LoadAllCAs indicates that tsh should load the CAs of all clusters // instead of just the current cluster. LoadAllCAs bool + + // AllowHeadless determines whether headless login can be used. Currently, only + // the ssh, scp, and ls commands can use headless login. Other commands will ignore + // headless auth connector and default to local instead. + AllowHeadless bool } // CachePolicy defines cache policy for local clients @@ -3172,7 +3177,12 @@ func (tc *TeleportClient) getSSHLoginFunc(pr *webclient.PingResponse) (SSHLoginF if !pr.Auth.AllowHeadless { return nil, trace.BadParameter("headless disallowed by cluster settings") } - // TODO (Joerger): Add headless login flow. + if tc.AllowHeadless { + return func(ctx context.Context, priv *keys.PrivateKey) (*auth.SSHLoginResponse, error) { + return tc.headlessLogin(ctx, priv) + }, nil + } + log.Debug("Headless login is disabled for this command. Only 'tsh ls', 'tsh ssh', and 'tsh scp' are supported. Defaulting to local authentication methods.") fallthrough case constants.LocalConnector, "": // if passwordless is enabled and there are passwordless credentials @@ -3446,6 +3456,28 @@ func (tc *TeleportClient) mfaLocalLogin(ctx context.Context, priv *keys.PrivateK return response, trace.Wrap(err) } +func (tc *TeleportClient) headlessLogin(ctx context.Context, priv *keys.PrivateKey) (*auth.SSHLoginResponse, error) { + headlessAuthenticationID := services.NewHeadlessAuthenticationID(priv.MarshalSSHPublicKey()) + fmt.Fprintf(tc.Stdout, "Complete headless authentication in your local web browser:\ntsh headless approve --user=%v --proxy=%v %v\n", tc.Username, tc.WebProxyAddr, headlessAuthenticationID) + + response, err := SSHAgentHeadlessLogin(ctx, SSHLoginHeadless{ + SSHLogin: SSHLogin{ + ProxyAddr: tc.WebProxyAddr, + PubKey: priv.MarshalSSHPublicKey(), + TTL: tc.KeyTTL, + Insecure: tc.InsecureSkipVerify, + Compatibility: tc.CertificateFormat, + KubernetesCluster: tc.KubernetesCluster, + }, + User: tc.Username, + HeadlessAuthenticationID: headlessAuthenticationID, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return response, nil +} + // SSOLoginFunc is a function used in tests to mock SSO logins. type SSOLoginFunc func(ctx context.Context, connectorID string, priv *keys.PrivateKey, protocol string) (*auth.SSHLoginResponse, error) @@ -4420,3 +4452,63 @@ func (tc *TeleportClient) RootClusterCACertPool(ctx context.Context) (*x509.Cert pool, err := key.clientCertPool(rootClusterName) return pool, trace.Wrap(err) } + +// HeadlessApprove handles approval of a headless authentication request. +func (tc *TeleportClient) HeadlessApprove(ctx context.Context, headlessAuthenticationID string) error { + ctx, span := tc.Tracer.Start( + ctx, + "teleportClient/HeadlessApprove", + oteltrace.WithSpanKind(oteltrace.SpanKindClient), + oteltrace.WithAttributes( + attribute.String("proxy", tc.Config.WebProxyAddr), + ), + ) + defer span.End() + + // connect to proxy first: + if !tc.Config.ProxySpecified() { + return trace.BadParameter("proxy server is not specified") + } + proxyClient, err := tc.ConnectToProxy(ctx) + if err != nil { + return trace.Wrap(err) + } + defer proxyClient.Close() + + headlessAuthn, err := proxyClient.currentCluster.GetHeadlessAuthentication(ctx, headlessAuthenticationID) + if err != nil { + return trace.Wrap(err) + } + + if headlessAuthn.State != types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_PENDING { + return trace.Errorf("cannot approve a headless authentication from a non-pending state: %v", headlessAuthn.State.Stringify()) + } + + confirmationPrompt := fmt.Sprintf("Headless login attempt from IP address %q requires approval.\nContact your administrator if you didn't initiate this login attempt.\nApprove?", headlessAuthn.ClientIpAddress) + ok, err := prompt.Confirmation(ctx, tc.Stdout, prompt.Stdin(), confirmationPrompt) + if err != nil { + return trace.Wrap(err) + } + + if !ok { + err = proxyClient.currentCluster.UpdateHeadlessAuthenticationState(ctx, headlessAuthenticationID, types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_DENIED, nil) + return trace.Wrap(err) + } + + chall, err := proxyClient.currentCluster.CreateAuthenticateChallenge(ctx, &proto.CreateAuthenticateChallengeRequest{ + Request: &proto.CreateAuthenticateChallengeRequest_ContextUser{ + ContextUser: &proto.ContextUser{}, + }, + }) + if err != nil { + return trace.Wrap(err) + } + + resp, err := tc.PromptMFAChallenge(ctx, tc.WebProxyAddr, chall, nil) + if err != nil { + return trace.Wrap(err) + } + + err = proxyClient.currentCluster.UpdateHeadlessAuthenticationState(ctx, headlessAuthenticationID, types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_APPROVED, resp) + return trace.Wrap(err) +} diff --git a/tool/tsh/tsh.go b/tool/tsh/tsh.go index 5b8c0b76d9d37..25b1792c3b0c8 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -432,6 +432,12 @@ type CLIConf struct { // tracer is the tracer used to trace tsh commands. tracer oteltrace.Tracer + + // Headless uses headless login for the client session. + Headless bool + + // HeadlessAuthenticationID is the ID of a headless authentication. + HeadlessAuthenticationID string } // Stdout returns the stdout writer. @@ -507,6 +513,7 @@ const ( loginEnvVar = "TELEPORT_LOGIN" bindAddrEnvVar = "TELEPORT_LOGIN_BIND_ADDR" proxyEnvVar = "TELEPORT_PROXY" + headlessEnvVar = "TELEPORT_HEADLESS" // TELEPORT_SITE uses the older deprecated "site" terminology to refer to a // cluster. All new code should use TELEPORT_CLUSTER instead. siteEnvVar = "TELEPORT_SITE" @@ -624,6 +631,7 @@ func Run(ctx context.Context, args []string, opts ...cliOption) error { Default(mfaModeAuto). Envar(mfaModeEnvVar). EnumVar(&cf.MFAMode, modes...) + app.Flag("headless", "Use headless login. Shorthand for --auth=headless.").Envar(headlessEnvVar).BoolVar(&cf.Headless) app.HelpFlag.Short('h') ver := app.Command("version", "Print the tsh client and Proxy server versions for the current context.") @@ -921,6 +929,11 @@ func Run(ctx context.Context, args []string, opts ...cliOption) error { reqSearch.Flag("kube-namespace", "Kubernetes Namespace to search for Pods").Default(corev1.NamespaceDefault).StringVar(&cf.kubeNamespace) reqSearch.Flag("all-kube-namespaces", "Search Pods in every namespace").BoolVar(&cf.kubeAllNamespaces) + // Headless login approval + headless := app.Command("headless", "headless commands").Interspersed(true) + approve := headless.Command("approve", "headless approval").Interspersed(true) + approve.Arg("request id", "headless authentication request id").StringVar(&cf.HeadlessAuthenticationID) + reqDrop := req.Command("drop", "Drop one more access requests from current identity") reqDrop.Arg("request-id", "IDs of requests to drop (default drops all requests)").Default("*").StringsVar(&cf.RequestIDs) kubectl := app.Command("kubectl", "Runs a kubectl command on a Kubernetes cluster").Interspersed(false) @@ -1232,6 +1245,8 @@ func Run(ctx context.Context, args []string, opts ...cliOption) error { case kubectl.FullCommand(): idx := slices.Index(args, kubectl.FullCommand()) err = onKubectlCommand(&cf, args[idx:]) + case approve.FullCommand(): + err = onHeadlessApprove(&cf) default: // Handle commands that might not be available. switch { @@ -1945,6 +1960,8 @@ func onListNodes(cf *CLIConf) error { return trace.Wrap(err) } + tc.AllowHeadless = true + // Get list of all nodes in backend and sort by "Node Name". var nodes []types.Server err = client.RetryWithRelogin(cf.Context, tc, func() error { @@ -3029,6 +3046,8 @@ func onSSH(cf *CLIConf) error { return trace.Wrap(err) } + tc.AllowHeadless = true + tc.Stdin = os.Stdin err = retryWithAccessRequest(cf, tc, func() error { err = client.RetryWithRelogin(cf.Context, tc, func() error { @@ -3147,6 +3166,8 @@ func onSCP(cf *CLIConf) error { return trace.Wrap(err) } + tc.AllowHeadless = true + // allow the file transfer to be gracefully stopped if the user wishes ctx, cancel := signal.NotifyContext(cf.Context, os.Interrupt) cf.Context = ctx @@ -3260,6 +3281,14 @@ func makeClientForProxy(cf *CLIConf, proxy string, useProfileLogin bool) (*clien c.JumpHosts = hosts } + // --headless is shorthand for --auth=headless + if cf.Headless { + if cf.AuthConnector != "" && cf.AuthConnector != constants.HeadlessConnector { + return nil, trace.BadParameter("either --headless or --auth can be specified, not both") + } + cf.AuthConnector = constants.HeadlessConnector + } + c.ClientStore, err = initClientStore(cf, proxy) if err != nil { return nil, trace.Wrap(err) @@ -3460,28 +3489,29 @@ func makeClientForProxy(cf *CLIConf, proxy string, useProfileLogin bool) (*clien } func initClientStore(cf *CLIConf, proxy string) (*client.Store, error) { - if cf.IdentityFileIn != "" { - keyStore, err := identityfile.NewClientStoreFromIdentityFile(cf.IdentityFileIn, proxy, cf.SiteName) + switch { + case cf.IdentityFileIn != "": + // Import identity file keys to in-memory client store. + clientStore, err := identityfile.NewClientStoreFromIdentityFile(cf.IdentityFileIn, proxy, cf.SiteName) if err != nil { return nil, trace.Wrap(err) } - return keyStore, nil - } + return clientStore, nil - // When logging in with an identity file output, we want to avoid writing - // any keys to disk, so we use a full memory client store. - if cf.IdentityFileOut != "" { + case cf.IdentityFileOut != "", cf.AuthConnector == constants.HeadlessConnector: + // Store client keys in memory, where they can be exported to non-standard + // FS formats (e.g. identity file) or used for a single client call in memory. return client.NewMemClientStore(), nil - } - - clientStore := client.NewFSClientStore(cf.HomePath) - // Store client keys in memory, but still save trusted certs and profile to disk. - if cf.AddKeysToAgent == client.AddKeysToAgentOnly { + case cf.AddKeysToAgent == client.AddKeysToAgentOnly: + // Store client keys in memory, but save trusted certs and profile to disk. + clientStore := client.NewFSClientStore(cf.HomePath) clientStore.KeyStore = client.NewMemKeyStore() - } + return clientStore, nil - return clientStore, nil + default: + return client.NewFSClientStore(cf.HomePath), nil + } } func (c *CLIConf) ProfileStatus() (*client.ProfileStatus, error) { @@ -4626,3 +4656,17 @@ func warnOnDeprecatedKubeConfigServerName(cf *CLIConf, tc *client.TeleportClient fmt.Printf("Deprecated tls-server-name value detected in %s KUBECONFIG file for [%v] clusters\n", kubeConfigPath, strings.Join(outdatedClusters, ", ")) fmt.Printf("Please re-login and update your KUBECONFIG cluster configuration by running the 'tsh kube login' command.\n\n") } + +// onHeadlessApprove executes 'tsh headless approve' command +func onHeadlessApprove(cf *CLIConf) error { + tc, err := makeClient(cf, false) + if err != nil { + return trace.Wrap(err) + } + + tc.Stdin = os.Stdin + err = client.RetryWithRelogin(cf.Context, tc, func() error { + return tc.HeadlessApprove(cf.Context, cf.HeadlessAuthenticationID) + }) + return trace.Wrap(err) +}