-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Headless Login: tsh implementation #22751
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this default to other auth method, or fail if headless auth was requested but not supported?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I currently have it set up to default to other auth methods. The primary use case for this is so that you can set < local machine > $ tsh login ...
# logs in with local authentication method
...
< remote machine > $ tsh ssh ...
# initiates headless
tsh approve request-id
...
< local machine > tsh approve request-id
# Uses local authenticaiton (~/.tsh) to approve |
||
| 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) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will the corresponding "decline" command be needed as well?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you enter "N" in the "y/N" prompt, it will count as a decline. I suppose "approve" is a bit misleading in that respect, but declining is more of an after thought as users usually won't need to go out of their way to decline a request. In the WebUI, we also use the "cancel" button for decline. |
||
| 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) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.