diff --git a/lib/client/api.go b/lib/client/api.go index c3b456af2f7e5..b45dd46e19811 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -25,6 +25,7 @@ import ( "fmt" "io" "net" + "net/url" "os" "os/exec" "os/user" @@ -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{ diff --git a/lib/client/weblogin.go b/lib/client/weblogin.go index 6916e42a22ba3..6ca06870ef304 100644 --- a/lib/client/weblogin.go +++ b/lib/client/weblogin.go @@ -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 diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index b4227adf1b0a8..93a5dc1728324 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -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 diff --git a/lib/web/headless.go b/lib/web/headless.go new file mode 100644 index 0000000000000..527bec83b33fd --- /dev/null +++ b/lib/web/headless.go @@ -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 +} diff --git a/web/packages/teleport/src/HeadlessRequest/Cards.tsx b/web/packages/teleport/src/HeadlessRequest/Cards.tsx new file mode 100644 index 0000000000000..8394a02b46513 --- /dev/null +++ b/web/packages/teleport/src/HeadlessRequest/Cards.tsx @@ -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 ( + + + {title && ( + + {title} + + )} + {children} + + ); +} + +export function CardAccept({ title, children }) { + return {children}; +} diff --git a/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx new file mode 100644 index 0000000000000..d9f5c82458660 --- /dev/null +++ b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx @@ -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( + + + + + + ); + + await expect( + screen.findByText(/Someone has initiated a command from 1.2.3.4/i) + ).resolves.toBeInTheDocument(); +}); diff --git a/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx new file mode 100644 index 0000000000000..a99c524e6e084 --- /dev/null +++ b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx @@ -0,0 +1,149 @@ +/** + * 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 React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { Spinner } from 'design/Icon'; +import { Box, Flex } from 'design'; + +import auth from 'teleport/services/auth'; +import { useParams } from 'teleport/components/Router'; +import HeadlessRequestDialog from 'teleport/components/HeadlessRequestDialog/HeadlessRequestDialog'; +import { CardAccept, CardDenied } from 'teleport/HeadlessRequest/Cards'; + +export function HeadlessRequest() { + const { requestId } = useParams<{ requestId: string }>(); + + const [state, setState] = useState({ + ipAddress: '', + status: 'pending', + errorText: '', + publicKey: null as PublicKeyCredentialRequestOptions, + }); + + useEffect(() => { + const setIpAddress = (response: { clientIpAddress: string }) => { + setState({ + ...state, + status: 'loaded', + ipAddress: response.clientIpAddress, + }); + }; + + auth + .headlessSSOGet(requestId) + .then(setIpAddress) + .catch(e => { + setState({ + ...state, + status: 'error', + errorText: e.toString(), + }); + }); + }, [requestId]); + + const setSuccess = () => { + setState({ ...state, status: 'success' }); + }; + + const setRejected = () => { + setState({ ...state, status: 'rejected' }); + }; + + if (state.status == 'pending') { + return ( + + + + + + ); + } + + if (state.status == 'success') { + return ( + + You can now return to your terminal. + + ); + } + + if (state.status == 'rejected') { + return ( + + The request has been rejected. + + ); + } + + return ( + { + setState({ ...state, status: 'in-progress' }); + + auth + .headlessSSOAccept(requestId) + .then(setSuccess) + .catch(e => { + setState({ + ...state, + status: 'error', + errorText: e.toString(), + }); + }); + }} + onReject={() => { + setState({ ...state, status: 'in-progress' }); + + auth + .headlessSSOReject(requestId) + .then(setRejected) + .catch(e => { + setState({ + ...state, + status: 'error', + errorText: e.toString(), + }); + }); + }} + errorText={state.errorText} + /> + ); +} + +const Spin = styled(Box)` + line-height: 12px; + font-size: 24px; + animation: spin 1s linear infinite; + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +`; diff --git a/web/packages/teleport/src/HeadlessRequest/index.ts b/web/packages/teleport/src/HeadlessRequest/index.ts new file mode 100644 index 0000000000000..702099fd9d384 --- /dev/null +++ b/web/packages/teleport/src/HeadlessRequest/index.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +// export as default for use with React.lazy +export { HeadlessRequest as default } from './HeadlessRequest'; diff --git a/web/packages/teleport/src/Teleport.tsx b/web/packages/teleport/src/Teleport.tsx index 9185846c8a27c..f91f44f05af8a 100644 --- a/web/packages/teleport/src/Teleport.tsx +++ b/web/packages/teleport/src/Teleport.tsx @@ -89,6 +89,10 @@ const DesktopSession = React.lazy( () => import(/* webpackChunkName: "desktop-session" */ './DesktopSession') ); +const HeadlessRequest = React.lazy( + () => import(/* webpackChunkName: "headless-request" */ './HeadlessRequest') +); + const Main = React.lazy(() => import(/* webpackChunkName: "main" */ './Main')); function publicOSSRoutes() { @@ -159,6 +163,11 @@ export function getSharedPrivateRoutes() { />, , , + , ]; } diff --git a/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx b/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx new file mode 100644 index 0000000000000..21a07120bbed1 --- /dev/null +++ b/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx @@ -0,0 +1,86 @@ +/** + * 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 React from 'react'; +import Dialog, { + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'design/Dialog'; +import { Danger } from 'design/Alert'; +import { ButtonPrimary, ButtonSecondary, Text } from 'design'; + +export default function HeadlessRequestDialog({ + ipAddress, + onAccept, + onReject, + errorText, +}: Props) { + return ( + ({ width: '400px' })} open={true}> + + + Host {ipAddress} wants to execute a command + + + + {errorText && ( + + {errorText} + + )} + + {errorText ? ( + <> + The requested session doesn't exist or is invalid. Please generate + a new request. +
+
+ You can close this window. + + ) : ( + <> + Someone has initiated a command from {ipAddress}. If it was not + you, click Reject and contact your administrator. +
+
+ If it was you, please use your hardware key to approve. + + )} +
+
+ + {!errorText && ( + <> + + Approve + + Reject + + )} + +
+ ); +} + +export type Props = { + ipAddress: string; + onAccept: () => void; + onReject: () => void; + errorText: string; +}; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 67400560bdbde..115674c6dac58 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -110,6 +110,7 @@ const cfg = { userReset: '/web/reset/:tokenId', userResetContinue: '/web/reset/:tokenId/continue', kubernetes: '/web/cluster/:clusterId/kubernetes', + headlessSso: `/web/headless/:requestId`, // whitelist sso handlers oidcHandler: '/v1/webapi/oidc/*', samlHandler: '/v1/webapi/saml/*', @@ -178,6 +179,8 @@ const cfg = { mfaLoginFinish: '/v1/webapi/mfa/login/finishsession', // creates a web session mfaChangePasswordBegin: '/v1/webapi/mfa/authenticatechallenge/password', + headlessSsoPath: `/v1/webapi/headless/:requestId`, + mfaCreateRegistrationChallengePath: '/v1/webapi/mfa/token/:tokenId/registerchallenge', @@ -199,6 +202,8 @@ const cfg = { captureUserEventPath: '/v1/webapi/capture', capturePreUserEventPath: '/v1/webapi/precapture', + + headlessLogin: '/v1/webapi/headless/:headless_authentication_id', }, getAppFqdnUrl(params: UrlAppParams) { @@ -425,6 +430,10 @@ const cfg = { return generatePath(cfg.routes.userResetContinue, { tokenId }); }, + getHeadlessSsoPath(requestId: string) { + return generatePath(cfg.api.headlessSsoPath, { requestId }); + }, + getUserInviteTokenRoute(tokenId = '') { return generatePath(cfg.routes.userInvite, { tokenId }); }, diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts index a662a2c79e6e9..d33e193de27c6 100644 --- a/web/packages/teleport/src/services/auth/auth.ts +++ b/web/packages/teleport/src/services/auth/auth.ts @@ -26,7 +26,7 @@ import { makeWebauthnAssertionResponse, makeWebauthnCreationResponse, } from './makeMfa'; -import { UserCredentials, NewCredentialRequest } from './types'; +import { NewCredentialRequest, UserCredentials } from './types'; const auth = { checkWebauthnSupport() { @@ -66,7 +66,7 @@ const auth = { // mfaLoginBegin retrieves users mfa challenges for their // registered devices. Empty creds indicates request for passwordless challenges. - // Otherwise non-passwordless challenges requires creds to be verified. + // Otherwise, non-passwordless challenges requires creds to be verified. mfaLoginBegin(creds?: UserCredentials) { return api .post(cfg.api.mfaLoginBegin, { @@ -195,6 +195,46 @@ const auth = { }); }, + headlessSSOGet(transactionId: string) { + return auth + .checkWebauthnSupport() + .then(() => api.get(cfg.getHeadlessSsoPath(transactionId))) + .then((json: any) => { + json = json || {}; + + return { + clientIpAddress: json.client_ip_address, + }; + }); + }, + + headlessSSOAccept(transactionId: string) { + return auth + .checkWebauthnSupport() + .then(() => api.post(cfg.api.mfaAuthnChallengePath)) + .then(res => + navigator.credentials.get({ + publicKey: makeMfaAuthenticateChallenge(res).webauthnPublicKey, + }) + ) + .then(res => { + const request = { + action: 'accept', + webauthnAssertionResponse: makeWebauthnAssertionResponse(res), + }; + + return api.put(cfg.getHeadlessSsoPath(transactionId), request); + }); + }, + + headlessSSOReject(transactionId: string) { + const request = { + action: 'denied', + }; + + return api.put(cfg.getHeadlessSsoPath(transactionId), request); + }, + createPrivilegeTokenWithTotp(secondFactorToken: string) { return api.post(cfg.api.createPrivilegeTokenPath, { secondFactorToken }); },