Skip to content

Commit

Permalink
Allow webauthn to be passed when issuing certs for web-based scp (#22864
Browse files Browse the repository at this point in the history
) (#23195)

* Allow webauthn to be passed when issuing certs for web-based scp

* Remove extra line
  • Loading branch information
avatus committed Mar 17, 2023
1 parent 6dce1c3 commit 16f94bb
Show file tree
Hide file tree
Showing 12 changed files with 262 additions and 76 deletions.
84 changes: 84 additions & 0 deletions lib/web/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,19 @@ limitations under the License.
package web

import (
"encoding/json"
"net/http"
"time"

"github.com/gravitational/trace"
"github.com/julienschmidt/httprouter"
"golang.org/x/crypto/ssh"

"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth"
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/reversetunnel"
"github.com/gravitational/teleport/lib/sshutils/scp"
Expand All @@ -44,6 +49,8 @@ type fileTransferRequest struct {
remoteLocation string
// filename is a file name
filename string
// webauthn is an optional parameter that contains a webauthn response string used to issue single use certs
webauthn string
}

func (h *Handler) transferFile(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
Expand All @@ -55,6 +62,7 @@ func (h *Handler) transferFile(w http.ResponseWriter, r *http.Request, p httprou
remoteLocation: query.Get("location"),
filename: query.Get("filename"),
namespace: defaults.Namespace,
webauthn: query.Get("webauthn"),
}

clt, err := sctx.GetUserClient(r.Context(), site)
Expand All @@ -68,6 +76,22 @@ func (h *Handler) transferFile(w http.ResponseWriter, r *http.Request, p httprou
proxyHostPort: h.ProxyHostPort(),
}

mfaReq, err := clt.IsMFARequired(r.Context(), &proto.IsMFARequiredRequest{
Target: &proto.IsMFARequiredRequest_Node{
Node: &proto.NodeLogin{
Node: p.ByName("server"),
Login: p.ByName("login"),
},
},
})
if err != nil {
return nil, trace.Wrap(err)
}

if mfaReq.Required && query.Get("webauthn") == "" {
return nil, trace.AccessDenied("MFA required for file transfer")
}

isUpload := r.Method == http.MethodPost
if isUpload {
err = ft.upload(req, r)
Expand Down Expand Up @@ -106,6 +130,13 @@ func (f *fileTransfer) download(req fileTransferRequest, httpReq *http.Request,
return trace.Wrap(err)
}

if req.webauthn != "" {
err = f.issueSingleUseCert(req.webauthn, httpReq, tc)
if err != nil {
return trace.Wrap(err)
}
}

err = tc.ExecuteSCP(httpReq.Context(), req.serverID, cmd)
if err != nil {
return trace.Wrap(err)
Expand All @@ -130,6 +161,13 @@ func (f *fileTransfer) upload(req fileTransferRequest, httpReq *http.Request) er
return trace.Wrap(err)
}

if req.webauthn != "" {
err = f.issueSingleUseCert(req.webauthn, httpReq, tc)
if err != nil {
return trace.Wrap(err)
}
}

err = tc.ExecuteSCP(httpReq.Context(), req.serverID, cmd)
if err != nil {
return trace.Wrap(err)
Expand Down Expand Up @@ -179,3 +217,49 @@ func (f *fileTransfer) createClient(req fileTransferRequest, httpReq *http.Reque

return tc, nil
}

type mfaRequest struct {
// WebauthnResponse is the response from authenticators.
WebauthnAssertionResponse *wanlib.CredentialAssertionResponse `json:"webauthnAssertionResponse"`
}

// issueSingleUseCert will take an assertion response sent from a solved challenge in the web UI
// and use that to generate a cert. This cert is added to the Teleport Client as an authmethod that
// can be used to connect to a node.
func (f *fileTransfer) issueSingleUseCert(webauthn string, httpReq *http.Request, tc *client.TeleportClient) error {
var mfaReq mfaRequest
err := json.Unmarshal([]byte(webauthn), &mfaReq)
if err != nil {
return trace.Wrap(err)
}

key, err := client.GenerateRSAKey()
if err != nil {
return trace.Wrap(err)
}

cert, err := f.authClient.GenerateUserCerts(httpReq.Context(), proto.UserCertsRequest{
PublicKey: key.MarshalSSHPublicKey(),
Username: f.ctx.GetUser(),
Expires: time.Now().Add(time.Minute).UTC(),
MFAResponse: &proto.MFAAuthenticateResponse{
Response: &proto.MFAAuthenticateResponse_Webauthn{
Webauthn: wanlib.CredentialAssertionResponseToProto(mfaReq.WebauthnAssertionResponse),
},
},
})
if err != nil {
return trace.Wrap(err)
}

key.Cert = cert.SSH

am, err := key.AsAuthMethod()
if err != nil {
return trace.Wrap(err)
}

tc.AuthMethods = []ssh.AuthMethod{am}

return nil
}
9 changes: 8 additions & 1 deletion web/packages/shared/components/FileTransfer/FileTransfer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
interface FileTransferProps {
backgroundColor?: string;
transferHandlers: TransferHandlers;
// errorText is any general error that isn't related to a specific transfer
errorText?: string;

/**
* `beforeClose` is called when an attempt to close the dialog was made
Expand Down Expand Up @@ -81,6 +83,7 @@ export function FileTransfer(props: FileTransferProps) {

return (
<FileTransferDialog
errorText={props.errorText}
openedDialog={openedDialog}
backgroundColor={props.backgroundColor}
transferHandlers={props.transferHandlers}
Expand All @@ -90,7 +93,10 @@ export function FileTransfer(props: FileTransferProps) {
}

export function FileTransferDialog(
props: Pick<FileTransferProps, 'transferHandlers' | 'backgroundColor'> & {
props: Pick<
FileTransferProps,
'transferHandlers' | 'backgroundColor' | 'errorText'
> & {
openedDialog: FileTransferDialogDirection;
onCloseDialog(isAnyTransferInProgress: boolean): void;
}
Expand Down Expand Up @@ -123,6 +129,7 @@ export function FileTransferDialog(

return (
<FileTransferStateless
errorText={props.errorText}
openedDialog={props.openedDialog}
files={filesStore.files}
onCancel={filesStore.cancel}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface FileTransferStatelessProps {
openedDialog: FileTransferDialogDirection;
files: TransferredFile[];
backgroundColor?: string;
// errorText is any general error that isn't related to a specific transfer
errorText?: string;

onClose(): void;

Expand Down Expand Up @@ -71,6 +73,9 @@ export function FileTransferStateless(props: FileTransferStatelessProps) {
<ButtonClose onClick={props.onClose} />
</Flex>
{items.Form}
<Text color="error.light" typography="body2" mt={1}>
{props.errorText}
</Text>
<FileList files={props.files} onCancel={props.onCancel} />
</Container>
);
Expand Down
66 changes: 44 additions & 22 deletions web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
FileTransferContextProvider,
} from 'shared/components/FileTransfer';

import cfg from 'teleport/config';
import * as stores from 'teleport/Console/stores';
import { colors } from 'teleport/Console/colors';

Expand All @@ -36,18 +35,22 @@ import Document from '../Document';
import Terminal from './Terminal';
import useSshSession from './useSshSession';
import { getHttpFileTransferHandlers } from './httpFileTransferHandlers';
import useGetScpUrl from './useGetScpUrl';

export default function DocumentSsh({ doc, visible }: PropTypes) {
const refTerminal = useRef<Terminal>();
const { tty, status, closeDocument } = useSshSession(doc);
const webauthn = useWebAuthn(tty);
const { getScpUrl, attempt: getMfaResponseAttempt } = useGetScpUrl(
webauthn.addMfaToScpUrls
);

function handleCloseFileTransfer() {
refTerminal.current.terminal.term.focus();
}

useEffect(() => {
if (refTerminal && refTerminal.current) {
if (refTerminal?.current) {
// when switching tabs or closing tabs, focus on visible terminal
refTerminal.current.terminal.term.focus();
}
Expand All @@ -74,32 +77,51 @@ export default function DocumentSsh({ doc, visible }: PropTypes) {
beforeClose={() =>
window.confirm('Are you sure you want to cancel file transfers?')
}
errorText={
getMfaResponseAttempt.status === 'failed'
? getMfaResponseAttempt.statusText
: null
}
afterClose={handleCloseFileTransfer}
backgroundColor={colors.primary.light}
transferHandlers={{
getDownloader: async (location, abortController) =>
getHttpFileTransferHandlers().download(
cfg.getScpUrl({
location,
clusterId: doc.clusterId,
serverId: doc.serverId,
login: doc.login,
filename: location,
}),
getDownloader: async (location, abortController) => {
const url = await getScpUrl({
location,
clusterId: doc.clusterId,
serverId: doc.serverId,
login: doc.login,
filename: location,
});
if (!url) {
// if we return nothing here, the file transfer will not be added to the
// file transfer list. If we add it to the list, the file will continue to
// start the download and return another here. This prevents a second network
// request that we know will fail.
return;
}
return getHttpFileTransferHandlers().download(
url,
abortController
),
getUploader: async (location, file, abortController) =>
getHttpFileTransferHandlers().upload(
cfg.getScpUrl({
location,
clusterId: doc.clusterId,
serverId: doc.serverId,
login: doc.login,
filename: file.name,
}),
);
},
getUploader: async (location, file, abortController) => {
const url = await getScpUrl({
location,
clusterId: doc.clusterId,
serverId: doc.serverId,
login: doc.login,
filename: file.name,
});
if (!url) {
return;
}
return getHttpFileTransferHandlers().upload(
url,
file,
abortController
),
);
},
}}
/>
</FileTransferContextProvider>
Expand Down
52 changes: 52 additions & 0 deletions web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* 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 useAttempt from 'shared/hooks/useAttemptNext';

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

export default function useGetScpUrl(addMfaToScpUrls: boolean) {
const { setAttempt, attempt, handleError } = useAttempt('');

async function getScpUrl(params: UrlScpParams) {
setAttempt({
status: 'processing',
statusText: '',
});
if (!addMfaToScpUrls) {
return cfg.getScpUrl(params);
}
try {
let webauthn = await auth.getWebauthnResponse();
setAttempt({
status: 'success',
statusText: '',
});
return cfg.getScpUrl({
webauthn,
...params,
});
} catch (error) {
handleError(error);
}
}

return {
getScpUrl,
attempt,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const props: State = {
requested: false,
authenticate: () => {},
setState: () => {},
addMfaToScpUrls: false,
},
isUsingChrome: true,
showAnotherSessionActiveDialog: false,
Expand Down Expand Up @@ -220,6 +221,7 @@ export const WebAuthnPrompt = () => (
requested: true,
authenticate: () => {},
setState: () => {},
addMfaToScpUrls: false,
}}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import useAttempt from 'shared/hooks/useAttemptNext';
import useTeleport from 'teleport/useTeleport';
import { useDiscover } from 'teleport/Discover/useDiscover';
import { DiscoverEventStatus } from 'teleport/services/userEvent';
import auth from 'teleport/services/auth/auth';
import { getDatabaseProtocol } from 'teleport/Discover/Database/resources';

import type {
Expand Down Expand Up @@ -63,7 +64,7 @@ export function useConnectionDiagnostic() {
try {
if (!mfaAuthnResponse) {
const mfaReq = getMfaRequest(req, resourceState);
const sessionMfa = await ctx.mfaService.isMfaRequired(mfaReq);
const sessionMfa = await auth.checkMfaRequired(mfaReq);
if (sessionMfa.required) {
setShowMfaDialog(true);
return;
Expand Down

0 comments on commit 16f94bb

Please sign in to comment.