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
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)
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.

At first it wasn't clear to me what happens to the single use cert that gets issued. Maybe a godoc on issueSingleUseCert that explains that it configures tc to use the new cert would help.

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(),
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.

Is 1 minute enough? Should we add a little buffer to account for clock drift, or is 1m what we use elsewhere too?

Copy link
Copy Markdown
Contributor Author

@avatus avatus Mar 13, 2023

Choose a reason for hiding this comment

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

1m is used in the same way for testing connections in Discovery. I think 1m is fine as this cert is being used directly after this in the same request handler and no where else. I suppose we could make the expiry shorter if we needed, but doesn't need to be longer.

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
Loading