Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/types/headlessauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
jakule marked this conversation as resolved.
Outdated
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"
}
}
94 changes: 93 additions & 1 deletion lib/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 cluster_auth_preference.connector = headless and it will still default to local connector when headless is not applicable.

< 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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
72 changes: 58 additions & 14 deletions tool/tsh/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will the corresponding "decline" command be needed as well?

Copy link
Copy Markdown
Contributor Author

@Joerger Joerger Mar 15, 2023

Choose a reason for hiding this comment

The 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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}