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
12 changes: 11 additions & 1 deletion lib/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"fmt"
"io"
"net"
"net/url"
"os"
"os/exec"
"os/user"
Expand Down Expand Up @@ -3450,7 +3451,16 @@ func (tc *TeleportClient) mfaLocalLogin(ctx context.Context, priv *keys.PrivateK

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)

webUILink, err := url.JoinPath("https://"+tc.WebProxyAddr, "web", "headless", headlessAuthenticationID)
if err != nil {
return nil, trace.Wrap(err)
}

tshApprove := fmt.Sprintf("tsh headless approve --user=%v --proxy=%v %v", tc.Username, tc.WebProxyAddr, headlessAuthenticationID)

fmt.Fprintf(tc.Stdout, "Complete headless authentication in your local web browser:\n\n%s\n"+
"\nor execute this command in your local terminal:\n\n%s\n", webUILink, tshApprove)

response, err := SSHAgentHeadlessLogin(ctx, SSHLoginHeadless{
SSHLogin: SSHLogin{
Expand Down
7 changes: 7 additions & 0 deletions lib/client/weblogin.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ type AuthenticateWebUserRequest struct {
WebauthnAssertionResponse *wanlib.CredentialAssertionResponse `json:"webauthnAssertionResponse,omitempty"`
}

type HeadlessRequest struct {
// Actions can be either accept or deny.
Action string `json:"action"`
// WebauthnAssertionResponse is a signed WebAuthn credential assertion.
WebauthnAssertionResponse *wanlib.CredentialAssertionResponse `json:"webauthnAssertionResponse,omitempty"`
}

// SSHLogin contains common SSH login parameters.
type SSHLogin struct {
// ProxyAddr is the target proxy address
Expand Down
3 changes: 3 additions & 0 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,9 @@ func (h *Handler) bindDefaultEndpoints() {
h.POST("/webapi/precapture", h.WithLimiter(h.createPreUserEventHandle))
// create authenticated user events.
h.POST("/webapi/capture", h.WithAuth(h.createUserEventHandle))

h.GET("/webapi/headless/:headless_authentication_id", h.WithAuth(h.getHeadless))
h.PUT("/webapi/headless/:headless_authentication_id", h.WithAuth(h.putHeadlessState))
}

// GetProxyClient returns authenticated auth server client
Expand Down
109 changes: 109 additions & 0 deletions lib/web/headless.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*

Copyright 2023 Gravitational, Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.


*/

package web

import (
"net/http"

"github.com/gravitational/trace"
"github.com/julienschmidt/httprouter"

"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/types"
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/httplib"
)

const headlessAuthID = "headless_authentication_id"

func (h *Handler) getHeadless(_ http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (any, error) {
headlessAuthenticationID, err := getHeadlessAuthID(params)
if err != nil {
return nil, trace.Wrap(err)
}

authClient, err := sctx.GetClient()
if err != nil {
return nil, trace.Wrap(err)
}

headlessAuthn, err := authClient.GetHeadlessAuthentication(r.Context(), headlessAuthenticationID)
if err != nil {
// Log the error, but return something more user-friendly.
// Context exceeded or invalid request states are more confusing than helpful.
h.log.Debug("failed to get headless session: %v", err)

return nil, trace.BadParameter("requested invalid headless session")
}

return headlessAuthn, nil
}

func (h *Handler) putHeadlessState(_ http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (any, error) {
headlessAuthenticationID, err := getHeadlessAuthID(params)
if err != nil {
return nil, trace.Wrap(err)
}

var req client.HeadlessRequest
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}

var action types.HeadlessAuthenticationState
var resp = &proto.MFAAuthenticateResponse{}

switch req.Action {
case "accept":
action = types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_APPROVED
resp = &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Webauthn{
Webauthn: wanlib.CredentialAssertionResponseToProto(req.WebauthnAssertionResponse),
},
}
case "denied":
action = types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_DENIED
default:
return nil, trace.BadParameter("unknown action %s", req.Action)
}

authClient, err := sctx.GetClient()
if err != nil {
return nil, trace.Wrap(err)
}

err = authClient.UpdateHeadlessAuthenticationState(r.Context(), headlessAuthenticationID,
action, resp)
if err != nil {
return nil, trace.Wrap(err)
}

// WebUI expects a JSON response.
return OK(), nil
}

func getHeadlessAuthID(params httprouter.Params) (string, error) {
headlessAuthenticationID := params.ByName(headlessAuthID)
if headlessAuthenticationID == "" {
return "", trace.BadParameter("request is missing headless authentication ID")
}
return headlessAuthenticationID, nil
}
39 changes: 39 additions & 0 deletions web/packages/teleport/src/HeadlessRequest/Cards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
*
* Copyright 2023 Gravitational, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

import { Card, CardSuccess, Text } from 'design';
import { CircleStop } from 'design/Icon';
import React from 'react';

export function CardDenied({ title, children }) {
return (
<Card width="540px" p={7} my={4} mx="auto" textAlign="center">
<CircleStop mb={3} fontSize={56} color="red" />
{title && (
<Text typography="h2" mb="4">
{title}
</Text>
)}
{children}
</Card>
);
}

export function CardAccept({ title, children }) {
return <CardSuccess title={title}>{children}</CardSuccess>;
}
51 changes: 51 additions & 0 deletions web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Copyright 2023 Gravitational, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

import { render, screen } from 'design/utils/testing';
import React from 'react';
import { Route, Router } from 'react-router';
import { createMemoryHistory } from 'history';

import { HeadlessRequest } from 'teleport/HeadlessRequest/HeadlessRequest';
import cfg from 'teleport/config';
import auth from 'teleport/services/auth';

test('ip address should be visible', async () => {
jest.spyOn(auth, 'headlessSSOGet').mockImplementation(
() =>
new Promise(resolve => {
resolve({ clientIpAddress: '1.2.3.4' });
})
);

const headlessSSOPath = '/web/headless/2a8dcaae-1fa5-533b-aad8-f97420df44de';
const mockHistory = createMemoryHistory({
initialEntries: [headlessSSOPath],
});

render(
<Router history={mockHistory}>
<Route path={cfg.routes.headlessSso}>
<HeadlessRequest />
</Route>
</Router>
);

await expect(
screen.findByText(/Someone has initiated a command from 1.2.3.4/i)
).resolves.toBeInTheDocument();
});
Loading