Implement headless watcher approval logic in the Electron App. (#29097)

This commit is contained in:
Brian Joerger 2023-08-01 11:44:47 -07:00 committed by GitHub
parent 6fb9f08108
commit 29ff71ef09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 781 additions and 63 deletions

View file

@ -593,7 +593,8 @@ func setupCollections(c *Cache, watches []types.WatchKind) (*cacheCollections, e
}
collections.byKind[resourceKind] = collections.integrations
case types.KindHeadlessAuthentication:
collections.byKind[resourceKind] = &genericCollection[*types.HeadlessAuthentication, noReader, headlessAuthenticationServiceExecutor]{cache: c, watch: watch}
// For headless authentications, we need only process events. We don't need to keep the cache up to date.
collections.byKind[resourceKind] = &genericCollection[*types.HeadlessAuthentication, noReader, noopExecutor]{cache: c, watch: watch}
case types.KindAccessList:
if c.AccessLists == nil {
return nil, trace.BadParameter("missing parameter AccessLists")
@ -2309,36 +2310,6 @@ func (integrationsExecutor) getReader(cache *Cache, cacheOK bool) services.Integ
var _ executor[types.Integration, services.IntegrationsGetter] = integrationsExecutor{}
type headlessAuthenticationServiceExecutor struct{}
func (headlessAuthenticationServiceExecutor) getAll(ctx context.Context, cache *Cache, loadSecrets bool) ([]*types.HeadlessAuthentication, error) {
return cache.headlessAuthenticationsCache.GetHeadlessAuthentications(ctx)
}
func (headlessAuthenticationServiceExecutor) upsert(ctx context.Context, cache *Cache, resource *types.HeadlessAuthentication) error {
return cache.headlessAuthenticationsCache.UpsertHeadlessAuthentication(ctx, resource)
}
func (headlessAuthenticationServiceExecutor) deleteAll(ctx context.Context, cache *Cache) error {
return cache.headlessAuthenticationsCache.DeleteAllHeadlessAuthentications(ctx)
}
func (headlessAuthenticationServiceExecutor) delete(ctx context.Context, cache *Cache, resource types.Resource) error {
ha, ok := resource.(*types.HeadlessAuthentication)
if !ok {
return trace.BadParameter("unexpected type %T", resource)
}
return cache.headlessAuthenticationsCache.DeleteHeadlessAuthentication(ctx, ha.User, resource.GetName())
}
func (headlessAuthenticationServiceExecutor) isSingleton() bool { return false }
func (headlessAuthenticationServiceExecutor) getReader(_ *Cache, _ bool) noReader {
return noReader{}
}
var _ executor[*types.HeadlessAuthentication, noReader] = headlessAuthenticationServiceExecutor{}
type accessListsExecutor struct{}
func (accessListsExecutor) getAll(ctx context.Context, cache *Cache, loadSecrets bool) ([]*accesslist.AccessList, error) {
@ -2369,3 +2340,31 @@ func (accessListsExecutor) getReader(cache *Cache, cacheOK bool) services.Access
}
var _ executor[*accesslist.AccessList, services.AccessListsGetter] = accessListsExecutor{}
// noopExecutor can be used when a resource's events do not need to processed by
// the cache itself, only passed on to other watchers.
type noopExecutor struct{}
func (noopExecutor) getAll(ctx context.Context, cache *Cache, loadSecrets bool) ([]*types.HeadlessAuthentication, error) {
return nil, nil
}
func (noopExecutor) upsert(ctx context.Context, cache *Cache, resource *types.HeadlessAuthentication) error {
return nil
}
func (noopExecutor) deleteAll(ctx context.Context, cache *Cache) error {
return nil
}
func (noopExecutor) delete(ctx context.Context, cache *Cache, resource types.Resource) error {
return nil
}
func (noopExecutor) isSingleton() bool { return false }
func (noopExecutor) getReader(_ *Cache, _ bool) noReader {
return noReader{}
}
var _ executor[*types.HeadlessAuthentication, noReader] = noopExecutor{}

View file

@ -1582,7 +1582,7 @@ type headlessAuthenticationParser struct {
func (p *headlessAuthenticationParser) parse(event backend.Event) (types.Resource, error) {
switch event.Type {
case types.OpDelete:
return resourceHeader(event, types.KindIntegration, types.V1, 0)
return resourceHeader(event, types.KindHeadlessAuthentication, types.V1, 0)
case types.OpPut:
ha, err := unmarshalHeadlessAuthentication(event.Item.Value)
if err != nil {

View file

@ -52,6 +52,44 @@ func (c *Cluster) WatchPendingHeadlessAuthentications(ctx context.Context) (watc
return watcher, close, trace.Wrap(err)
}
// WatchHeadlessAuthentications watches the backend for headless authentication events for the user.
func (c *Cluster) WatchHeadlessAuthentications(ctx context.Context) (watcher types.Watcher, close func(), err error) {
proxyClient, err := c.clusterClient.ConnectToProxy(ctx)
if err != nil {
return nil, nil, trace.Wrap(err)
}
rootClient, err := proxyClient.ConnectToRootCluster(ctx)
if err != nil {
proxyClient.Close()
return nil, nil, trace.Wrap(err)
}
watch := types.Watch{
Kinds: []types.WatchKind{{
Kind: types.KindHeadlessAuthentication,
Filter: (&types.HeadlessAuthenticationFilter{
Username: c.clusterClient.Username,
}).IntoMap(),
}},
}
watcher, err = rootClient.NewWatcher(ctx, watch)
if err != nil {
proxyClient.Close()
rootClient.Close()
return nil, nil, trace.Wrap(err)
}
close = func() {
watcher.Close()
proxyClient.Close()
rootClient.Close()
}
return watcher, close, trace.Wrap(err)
}
// UpdateHeadlessAuthenticationState updates the headless authentication matching the given id to the given state.
// MFA will be prompted when updating to the approve state.
func (c *Cluster) UpdateHeadlessAuthenticationState(ctx context.Context, headlessID string, state types.HeadlessAuthenticationState) error {

View file

@ -34,11 +34,20 @@ import (
usagereporter "github.com/gravitational/teleport/lib/usagereporter/daemon"
)
// tshdEventsTimeout is the maximum amount of time the gRPC client managed by the tshd daemon will
// wait for a response from the tshd events server managed by the Electron app. This timeout
// should be used for quick one-off calls where the client doesn't need the server or the user to
// perform any additional work, such as the SendNotification RPC.
const tshdEventsTimeout = time.Second
const (
// tshdEventsTimeout is the maximum amount of time the gRPC client managed by the tshd daemon will
// wait for a response from the tshd events server managed by the Electron app. This timeout
// should be used for quick one-off calls where the client doesn't need the server or the user to
// perform any additional work, such as the SendNotification RPC.
tshdEventsTimeout = time.Second
// imporantModalWaitDuraiton is the amount of time to wait between sending tshd events that
// display important modals in the Electron App. This ensures a clear transition between modals.
imporantModalWaitDuraiton = time.Second / 2
// The Electron App can only display one important modal at a time.
maxConcurrentImportantModals = 1
)
// New creates an instance of Daemon service
func New(cfg Config) (*Service, error) {
@ -68,13 +77,18 @@ func New(cfg Config) (*Service, error) {
// relogin makes the Electron app display a login modal to trigger re-login.
func (s *Service) relogin(ctx context.Context, req *api.ReloginRequest) error {
// The Electron app cannot display two login modals at the same time, so we have to cut short any
// concurrent relogin requests.
// Relogin may be triggered by multiple gateways simultaneously. To prevent
// redundant relogin requests, cut short additional relogin requests.
if !s.reloginMu.TryLock() {
return trace.AlreadyExists("another relogin request is in progress")
}
defer s.reloginMu.Unlock()
if err := s.importantModalSemaphore.Acquire(ctx); err != nil {
return trace.Wrap(err)
}
defer s.importantModalSemaphore.Release()
const reloginUserTimeout = time.Minute
timeoutCtx, cancelTshdEventsCtx := context.WithTimeout(ctx, reloginUserTimeout)
defer cancelTshdEventsCtx()
@ -223,7 +237,7 @@ func (s *Service) ClusterLogout(ctx context.Context, uri string) error {
return trace.Wrap(err)
}
if err := s.StopHeadlessWatcher(uri); err != nil {
if err := s.StopHeadlessWatcher(uri); err != nil && !trace.IsNotFound(err) {
return trace.Wrap(err)
}
@ -671,6 +685,7 @@ func (s *Service) UpdateAndDialTshdEventsServerAddress(serverAddress string) err
client := api.NewTshdEventsServiceClient(conn)
s.tshdEventsClient = client
s.importantModalSemaphore = newWaitSemaphore(maxConcurrentImportantModals, imporantModalWaitDuraiton)
// Resume headless watchers for any active login sessions.
if err := s.StartHeadlessWatchers(); err != nil {
@ -818,6 +833,15 @@ type Service struct {
gateways map[string]gateway.Gateway
// tshdEventsClient is a client to send events to the Electron App.
tshdEventsClient api.TshdEventsServiceClient
// The Electron App can only display one important Modal at a time. tshd events
// that trigger an important modal (relogin, headless login) should use this
// lock to ensure it doesn't overwrite existing tshd-initiated important modals.
//
// We use a semaphore instead of a mutex in order to cancel important modals that
// are no longer relevant before acquisition.
//
// We use a waitSemaphore in order to make sure there is a clear transition between modals.
importantModalSemaphore *waitSemaphore
// usageReporter batches the events and sends them to prehog
usageReporter *usagereporter.UsageReporter
// reloginMu is used when a goroutine needs to request a relogin from the Electron app. Since the
@ -834,3 +858,33 @@ type CreateGatewayParams struct {
TargetSubresourceName string
LocalPort string
}
// waitSemaphore is a semaphore that waits for a specified duration between acquisitions.
type waitSemaphore struct {
semC chan struct{}
lastRelease time.Time
waitDuration time.Duration
}
func newWaitSemaphore(maxConcurrency int, waitDuration time.Duration) *waitSemaphore {
return &waitSemaphore{
semC: make(chan struct{}, maxConcurrency),
waitDuration: waitDuration,
}
}
func (s *waitSemaphore) Acquire(ctx context.Context) error {
select {
case s.semC <- struct{}{}:
// wait up to the specified wait duration before returning.
time.Sleep(s.waitDuration - time.Since(s.lastRelease))
return nil
case <-ctx.Done():
return trace.Wrap(ctx.Err())
}
}
func (s *waitSemaphore) Release() {
s.lastRelease = time.Now()
<-s.semC
}

View file

@ -16,6 +16,8 @@ package daemon
import (
"context"
"strings"
"sync"
"github.com/gravitational/trace"
@ -100,17 +102,43 @@ func (s *Service) startHeadlessWatcher(cluster *clusters.Cluster) error {
watchCtx, watchCancel := context.WithCancel(s.closeContext)
s.headlessWatcherClosers[cluster.URI.String()] = watchCancel
log := s.cfg.Log.WithField("cluster", cluster.URI.String())
pendingRequests := make(map[string]context.CancelFunc)
pendingRequestsMu := sync.Mutex{}
cancelPendingRequest := func(name string) {
pendingRequestsMu.Lock()
defer pendingRequestsMu.Unlock()
if cancel, ok := pendingRequests[name]; ok {
cancel()
}
}
addPendingRequest := func(name string, cancel context.CancelFunc) {
pendingRequestsMu.Lock()
defer pendingRequestsMu.Unlock()
pendingRequests[name] = cancel
}
watch := func() error {
watcher, closeWatcher, err := cluster.WatchPendingHeadlessAuthentications(watchCtx)
pendingWatcher, closePendingWatcher, err := cluster.WatchPendingHeadlessAuthentications(watchCtx)
if err != nil {
return trace.Wrap(err)
}
defer closePendingWatcher()
resolutionWatcher, closeResolutionWatcher, err := cluster.WatchHeadlessAuthentications(watchCtx)
if err != nil {
return trace.Wrap(err)
}
defer closeResolutionWatcher()
retry.Reset()
defer closeWatcher()
for {
select {
case event := <-watcher.Events():
case event := <-pendingWatcher.Events():
// Ignore non-put events.
if event.Type != types.OpPut {
continue
@ -121,25 +149,49 @@ func (s *Service) startHeadlessWatcher(cluster *clusters.Cluster) error {
return trace.Errorf("headless watcher returned an unexpected resource type %T", event.Resource)
}
// Notify the Electron App of the pending headless authentication to handle resolution.
req := &api.SendPendingHeadlessAuthenticationRequest{
RootClusterUri: cluster.URI.String(),
HeadlessAuthenticationId: ha.GetName(),
HeadlessAuthenticationClientIp: ha.ClientIpAddress,
}
// headless authentication requests will timeout after 3 minutes, so we can close the
// Electron modal once this time is up.
sendCtx, cancelSend := context.WithTimeout(s.closeContext, defaults.CallbackTimeout)
if _, err := s.tshdEventsClient.SendPendingHeadlessAuthentication(watchCtx, req); err != nil {
return trace.Wrap(err)
// Add the pending request to the map so it is canceled early upon resolution.
addPendingRequest(ha.GetName(), cancelSend)
// Notify the Electron App of the pending headless authentication to handle resolution.
// We do this in a goroutine so the watch loop can continue and cancel resolved requests.
go func() {
defer cancelSend()
if err := s.sendPendingHeadlessAuthentication(sendCtx, ha, cluster.URI.String()); err != nil {
if !strings.Contains(err.Error(), context.Canceled.Error()) && !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
log.WithError(err).Debug("sendPendingHeadlessAuthentication resulted in unexpected error.")
}
}
}()
case event := <-resolutionWatcher.Events():
// Watch for pending headless authentications to be approved, denied, or deleted (canceled/timeout).
switch event.Type {
case types.OpPut:
ha, ok := event.Resource.(*types.HeadlessAuthentication)
if !ok {
return trace.Errorf("headless watcher returned an unexpected resource type %T", event.Resource)
}
switch ha.State {
case types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_APPROVED, types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_DENIED:
cancelPendingRequest(ha.GetName())
}
case types.OpDelete:
cancelPendingRequest(event.Resource.GetName())
}
case <-watcher.Done():
return trace.Wrap(watcher.Error())
case <-pendingWatcher.Done():
return trace.Wrap(pendingWatcher.Error(), "pending watcher error")
case <-resolutionWatcher.Done():
return trace.Wrap(resolutionWatcher.Error(), "resolution watcher error")
case <-watchCtx.Done():
return nil
}
}
}
log := s.cfg.Log.WithField("cluster", cluster.URI.String())
log.Debugf("Starting headless watch loop.")
go func() {
defer func() {
@ -185,6 +237,23 @@ func (s *Service) startHeadlessWatcher(cluster *clusters.Cluster) error {
return nil
}
// sendPendingHeadlessAuthentication notifies the Electron App of a pending headless authentication.
func (s *Service) sendPendingHeadlessAuthentication(ctx context.Context, ha *types.HeadlessAuthentication, clusterURI string) error {
req := &api.SendPendingHeadlessAuthenticationRequest{
RootClusterUri: clusterURI,
HeadlessAuthenticationId: ha.GetName(),
HeadlessAuthenticationClientIp: ha.ClientIpAddress,
}
if err := s.importantModalSemaphore.Acquire(ctx); err != nil {
return trace.Wrap(err)
}
defer s.importantModalSemaphore.Release()
_, err := s.tshdEventsClient.SendPendingHeadlessAuthentication(ctx, req)
return trace.Wrap(err)
}
// StopHeadlessWatcher stops the headless watcher for the given cluster URI.
func (s *Service) StopHeadlessWatcher(uri string) error {
s.headlessWatcherClosersMu.Lock()

View file

@ -20,6 +20,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
@ -28,6 +29,7 @@ import (
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"github.com/gravitational/teleport/api/types"
api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/teleterm/api/uri"
@ -457,6 +459,102 @@ func TestRetryWithRelogin(t *testing.T) {
}
}
func TestImportantModalSemaphore(t *testing.T) {
t.Parallel()
ctx := context.Background()
storage, err := clusters.NewStorage(clusters.Config{
Dir: t.TempDir(),
InsecureSkipVerify: true,
})
require.NoError(t, err)
daemon, err := New(Config{
Storage: storage,
CreateTshdEventsClientCredsFunc: func() (grpc.DialOption, error) {
return grpc.WithTransportCredentials(insecure.NewCredentials()), nil
},
})
require.NoError(t, err)
service, addr := newMockTSHDEventsServiceServer(t)
err = daemon.UpdateAndDialTshdEventsServerAddress(addr)
require.NoError(t, err)
// Claim the important modal semaphore.
customWaitDuration := 10 * time.Millisecond
daemon.importantModalSemaphore.waitDuration = customWaitDuration
err = daemon.importantModalSemaphore.Acquire(ctx)
require.NoError(t, err)
// relogin and sending pending headless authentications should be blocked.
reloginErrC := make(chan error)
go func() {
reloginErrC <- daemon.relogin(ctx, &api.ReloginRequest{})
}()
sphaErrC := make(chan error)
go func() {
sphaErrC <- daemon.sendPendingHeadlessAuthentication(ctx, &types.HeadlessAuthentication{}, "")
}()
select {
case <-reloginErrC:
t.Error("relogin completed successfully without acquiring the important modal semaphore")
case <-sphaErrC:
t.Error("sendPendingHeadlessAuthentication completed successfully without acquiring the important modal semaphore")
case <-time.After(5 * customWaitDuration):
}
// if the request's ctx is canceled, they will unblock and return an error instead.
cancelCtx, cancel := context.WithCancel(ctx)
cancel()
err = daemon.relogin(cancelCtx, &api.ReloginRequest{})
require.Error(t, err)
err = daemon.sendPendingHeadlessAuthentication(cancelCtx, &types.HeadlessAuthentication{}, "")
require.Error(t, err)
// Release the semaphore. relogin and sending pending headless authentication should
// complete successfully after a short delay between each semaphore release.
releaseTime := time.Now()
daemon.importantModalSemaphore.Release()
var otherC chan error
select {
case err := <-reloginErrC:
require.NoError(t, err)
otherC = sphaErrC
case err := <-sphaErrC:
require.NoError(t, err)
otherC = reloginErrC
case <-time.After(2 * customWaitDuration):
t.Error("important modal operations failed to acquire unclaimed semaphore")
}
if time.Since(releaseTime) < customWaitDuration {
t.Error("important modal semaphore should not be acquired before waiting the specified duration")
}
select {
case err := <-otherC:
require.NoError(t, err)
case <-time.After(2 * customWaitDuration):
t.Error("important modal operations failed to acquire unclaimed semaphore")
}
if time.Since(releaseTime) < 2*customWaitDuration {
t.Error("important modal semaphore should not be acquired before waiting the specified duration")
}
require.Equal(t, 1, service.callCounts["Relogin"], "Unexpected number of calls to service.Relogin")
require.Equal(t, 1, service.callCounts["SendPendingHeadlessAuthentication"], "Unexpected number of calls to service.SendPendingHeadlessAuthentication")
}
type mockTSHDEventsService struct {
*api.UnimplementedTshdEventsServiceServer
callCounts map[string]int
@ -507,3 +605,8 @@ func (c *mockTSHDEventsService) SendNotification(context.Context, *api.SendNotif
c.callCounts["SendNotification"]++
return &api.SendNotificationResponse{}, nil
}
func (c *mockTSHDEventsService) SendPendingHeadlessAuthentication(context.Context, *api.SendPendingHeadlessAuthenticationRequest) (*api.SendPendingHeadlessAuthenticationResponse, error) {
c.callCounts["SendPendingHeadlessAuthentication"]++
return &api.SendPendingHeadlessAuthenticationResponse{}, nil
}

View file

@ -30,7 +30,10 @@ import middleware, { withLogging } from './middleware';
import * as types from './types';
import createAbortController from './createAbortController';
import { mapUsageEvent } from './mapUsageEvent';
import { ReportUsageEventRequest } from './types';
import {
ReportUsageEventRequest,
UpdateHeadlessAuthenticationStateParams,
} from './types';
export default function createClient(
addr: string,
@ -703,6 +706,28 @@ export default function createClient(
);
});
},
async updateHeadlessAuthenticationState(
params: UpdateHeadlessAuthenticationStateParams,
abortSignal?: types.TshAbortSignal
) {
return withAbort(abortSignal, callRef => {
const req = new api.UpdateHeadlessAuthenticationStateRequest()
.setRootClusterUri(params.rootClusterUri)
.setHeadlessAuthenticationId(params.headlessAuthenticationId)
.setState(params.state);
return new Promise<void>((resolve, reject) => {
callRef.current = tshd.updateHeadlessAuthenticationState(req, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
});
},
};
return client;

View file

@ -36,6 +36,7 @@ import {
GetRequestableRolesResponse,
CreateConnectMyComputerRoleResponse,
CreateConnectMyComputerNodeTokenResponse,
UpdateHeadlessAuthenticationStateParams,
} from '../types';
export class MockTshClient implements TshClient {
@ -103,4 +104,8 @@ export class MockTshClient implements TshClient {
createConnectMyComputerRole: () => Promise<CreateConnectMyComputerRoleResponse>;
createConnectMyComputerNodeToken: () => Promise<CreateConnectMyComputerNodeTokenResponse>;
deleteConnectMyComputerToken: () => Promise<void>;
updateHeadlessAuthenticationState: (
params: UpdateHeadlessAuthenticationStateParams
) => Promise<void>;
}

View file

@ -29,6 +29,7 @@ import apiKube from 'gen-proto-js/teleport/lib/teleterm/v1/kube_pb';
import apiLabel from 'gen-proto-js/teleport/lib/teleterm/v1/label_pb';
import apiService, {
FileTransferDirection,
HeadlessAuthenticationState,
} from 'gen-proto-js/teleport/lib/teleterm/v1/service_pb';
import apiAuthSettings from 'gen-proto-js/teleport/lib/teleterm/v1/auth_settings_pb';
import apiAccessRequest from 'gen-proto-js/teleport/lib/teleterm/v1/access_request_pb';
@ -246,6 +247,11 @@ export type TshClient = {
clusterUri: uri.RootClusterUri,
token: string
) => Promise<void>;
updateHeadlessAuthenticationState: (
params: UpdateHeadlessAuthenticationStateParams,
abortSignal?: TshAbortSignal
) => Promise<void>;
};
export type TshAbortController = {
@ -341,3 +347,11 @@ export type CreateConnectMyComputerNodeTokenResponse =
// Replaces object property with a new type
type Modify<T, R> = Omit<T, keyof R> & R;
export type UpdateHeadlessAuthenticationStateParams = {
rootClusterUri: uri.RootClusterUri;
headlessAuthenticationId: string;
state: apiService.HeadlessAuthenticationState;
};
export { HeadlessAuthenticationState };

View file

@ -42,6 +42,11 @@ export interface CannotProxyGatewayConnection
targetUri: uri.DatabaseUri;
}
export interface SendPendingHeadlessAuthenticationRequest
extends api.SendPendingHeadlessAuthenticationRequest.AsObject {
rootClusterUri: uri.RootClusterUri;
}
/**
* Starts tshd events server.
* @return {Promise} Object containing the address the server is listening on and subscribeToEvent
@ -180,10 +185,25 @@ function createService(logger: Logger): {
}
);
},
sendPendingHeadlessAuthentication: () => {
// TODO (joerger): Handle pending headless authentications with an
// approve/deny modal, followed by an MFA prompt for approval.
logger.info('Received pending headless authentication');
sendPendingHeadlessAuthentication: (call, callback) => {
const request = call.request.toObject();
logger.info('Emitting sendPendingHeadlessAuthentication', request);
const onCancelled = (callback: () => void) => {
call.on('cancelled', callback);
};
emitter
.emit('sendPendingHeadlessAuthentication', { request, onCancelled })
.then(
() => {
callback(null, new api.SendPendingHeadlessAuthenticationResponse());
},
error => {
callback(error);
}
);
},
};

View file

@ -25,6 +25,8 @@ import svgHardwareKey from './hardware.svg';
import type { WebauthnLogin } from '../../useClusterLogin';
// PromptWebauthn is reused in HeadlessPrompt as well.
// TODO(ravicious): Extract PromptWebauthn to a better location.
export function PromptWebauthn(props: Props) {
const { prompt } = props;
return (

View file

@ -0,0 +1,85 @@
/**
* 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, { useRef } from 'react';
import { useAsync } from 'shared/hooks/useAsync';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { RootClusterUri } from 'teleterm/ui/uri';
import { HeadlessAuthenticationState } from 'teleterm/services/tshd/types';
import { HeadlessPrompt } from './HeadlessPrompt';
interface HeadlessAuthenticationProps {
rootClusterUri: RootClusterUri;
headlessAuthenticationId: string;
clientIp: string;
onCancel(): void;
onSuccess(): void;
}
export function HeadlessAuthentication(props: HeadlessAuthenticationProps) {
const { headlessAuthenticationService, clustersService } = useAppContext();
const refAbortCtrl = useRef(clustersService.client.createAbortController());
const cluster = clustersService.findCluster(props.rootClusterUri);
const [updateHeadlessStateAttempt, updateHeadlessState] = useAsync(
(state: HeadlessAuthenticationState) =>
headlessAuthenticationService.updateHeadlessAuthenticationState(
{
rootClusterUri: props.rootClusterUri,
headlessAuthenticationId: props.headlessAuthenticationId,
state: state,
},
refAbortCtrl.current.signal
)
);
async function handleHeadlessApprove(): Promise<void> {
const [, error] = await updateHeadlessState(
HeadlessAuthenticationState.HEADLESS_AUTHENTICATION_STATE_APPROVED
);
if (!error) {
props.onSuccess();
}
}
async function handleHeadlessReject(): Promise<void> {
const [, error] = await updateHeadlessState(
HeadlessAuthenticationState.HEADLESS_AUTHENTICATION_STATE_DENIED
);
if (!error) {
props.onSuccess();
}
}
return (
<HeadlessPrompt
cluster={cluster}
clientIp={props.clientIp}
onApprove={handleHeadlessApprove}
onReject={handleHeadlessReject}
headlessAuthenticationId={props.headlessAuthenticationId}
updateHeadlessStateAttempt={updateHeadlessStateAttempt}
onCancel={() => {
props.onCancel();
refAbortCtrl.current.abort();
}}
/>
);
}

View file

@ -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 React from 'react';
import { makeEmptyAttempt } from 'shared/hooks/useAsync';
import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
import { HeadlessPrompt } from './HeadlessPrompt';
export default {
title: 'Teleterm/ModalsHost/HeadlessPrompt',
};
export const Story = () => (
<HeadlessPrompt
cluster={makeRootCluster()}
clientIp="localhost"
onApprove={async () => {}}
onReject={async () => {}}
updateHeadlessStateAttempt={makeEmptyAttempt<void>()}
onCancel={() => {}}
headlessAuthenticationId="85fa45fa-57f4-5a9d-9ba8-b3cbf76d5ea2"
/>
);

View file

@ -0,0 +1,123 @@
/**
* Copyright 2021 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, { useState } from 'react';
import * as Alerts from 'design/Alert';
import { ButtonIcon, Text, ButtonSecondary } from 'design';
import DialogConfirmation, {
DialogContent,
DialogHeader,
DialogFooter,
} from 'design/DialogConfirmation';
import { Attempt } from 'shared/hooks/useAsync';
import * as Icons from 'design/Icon';
import { PromptWebauthn } from '../../ClusterConnect/ClusterLogin/FormLogin/PromptWebauthn';
import type * as tsh from 'teleterm/services/tshd/types';
export type HeadlessPromptProps = {
cluster: tsh.Cluster;
clientIp: string;
onApprove(): Promise<void>;
onReject(): Promise<void>;
headlessAuthenticationId: string;
updateHeadlessStateAttempt: Attempt<void>;
onCancel(): void;
};
export function HeadlessPrompt({
cluster,
clientIp,
onApprove,
onReject,
headlessAuthenticationId,
updateHeadlessStateAttempt,
onCancel,
}: HeadlessPromptProps) {
const [waitForMfa, setWaitForMfa] = useState(false);
return (
<DialogConfirmation
dialogCss={() => ({
maxWidth: '480px',
width: '100%',
})}
disableEscapeKeyDown={false}
open={true}
>
<DialogHeader justifyContent="space-between" mb={0} alignItems="baseline">
<Text typography="h4">
Headless command on <b>{cluster.name}</b>
</Text>
<ButtonIcon type="button" onClick={onCancel} color="text.slightlyMuted">
<Icons.Close fontSize={5} />
</ButtonIcon>
</DialogHeader>
{updateHeadlessStateAttempt.status === 'error' && (
<Alerts.Danger mb={0}>
{updateHeadlessStateAttempt.statusText}
</Alerts.Danger>
)}
{!waitForMfa && (
<>
<DialogContent>
<Text color="text.slightlyMuted">
Someone initiated a headless command from <b>{clientIp}</b>.
<br />
If it was not you, click Reject and contact your administrator.
</Text>
<Text color="text.muted" mt={1} fontSize="12px">
Request ID: {headlessAuthenticationId}
</Text>
</DialogContent>
<DialogFooter>
<ButtonSecondary
autoFocus
mr={3}
type="submit"
onClick={e => {
e.preventDefault();
setWaitForMfa(true);
onApprove();
}}
>
Approve
</ButtonSecondary>
<ButtonSecondary
type="button"
onClick={e => {
e.preventDefault();
onReject();
}}
>
Reject
</ButtonSecondary>
</DialogFooter>
</>
)}
{waitForMfa && (
<DialogContent mb={2}>
<Text color="text.slightlyMuted">
Complete MFA verification to approve the Headless Login.
</Text>
<PromptWebauthn prompt={'tap'} onCancel={onCancel} />
</DialogContent>
)}
</DialogConfirmation>
);
}

View file

@ -0,0 +1,17 @@
/*
Copyright 2021 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 { HeadlessPrompt } from './HeadlessPrompt';

View file

@ -0,0 +1,17 @@
/**
* 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 * from './HeadlessAuthentication';

View file

@ -21,6 +21,7 @@ import { useAppContext } from 'teleterm/ui/appContextProvider';
import { ClusterConnect } from 'teleterm/ui/ClusterConnect';
import { DocumentsReopen } from 'teleterm/ui/DocumentsReopen';
import { Dialog } from 'teleterm/ui/services/modals';
import { HeadlessAuthentication } from 'teleterm/ui/HeadlessAuthn';
import { ClusterLogout } from '../ClusterLogout';
import { ResourceSearchErrors } from '../Search/ResourceSearchErrors';
@ -132,6 +133,24 @@ function renderDialog(dialog: Dialog, handleClose: () => void) {
);
}
case 'headless-authn': {
return (
<HeadlessAuthentication
rootClusterUri={dialog.rootClusterUri}
headlessAuthenticationId={dialog.headlessAuthenticationId}
clientIp={dialog.headlessAuthenticationClientIp}
onCancel={() => {
handleClose();
dialog.onCancel();
}}
onSuccess={() => {
handleClose();
dialog.onSuccess();
}}
/>
);
}
case 'none': {
return null;
}

View file

@ -22,6 +22,7 @@ import {
import {
ReloginRequest,
SendNotificationRequest,
SendPendingHeadlessAuthenticationRequest,
} from 'teleterm/services/tshdEvents';
import { ClustersService } from 'teleterm/ui/services/clusters';
import { ModalsService } from 'teleterm/ui/services/modals';
@ -32,8 +33,9 @@ import { KeyboardShortcutsService } from 'teleterm/ui/services/keyboardShortcuts
import { WorkspacesService } from 'teleterm/ui/services/workspacesService/workspacesService';
import { NotificationsService } from 'teleterm/ui/services/notifications';
import { FileTransferService } from 'teleterm/ui/services/fileTransferClient';
import { ReloginService } from 'teleterm/services/relogin';
import { TshdNotificationsService } from 'teleterm/services/tshdNotifications';
import { ReloginService } from 'teleterm/ui/services/relogin/reloginService';
import { TshdNotificationsService } from 'teleterm/ui/services/tshdNotifications/tshdNotificationService';
import { HeadlessAuthenticationService } from 'teleterm/ui/services/headlessAuthn/headlessAuthnService';
import { UsageService } from 'teleterm/ui/services/usage';
import { ResourcesService } from 'teleterm/ui/services/resources';
import { ConnectMyComputerService } from 'teleterm/ui/services/connectMyComputer';
@ -71,6 +73,7 @@ export default class AppContext implements IAppContext {
subscribeToTshdEvent: SubscribeToTshdEvent;
reloginService: ReloginService;
tshdNotificationsService: TshdNotificationsService;
headlessAuthenticationService: HeadlessAuthenticationService;
usageService: UsageService;
configService: ConfigService;
connectMyComputerService: ConnectMyComputerService;
@ -137,6 +140,11 @@ export default class AppContext implements IAppContext {
this.mainProcessClient,
tshClient
);
this.headlessAuthenticationService = new HeadlessAuthenticationService(
mainProcessClient,
this.modalsService,
tshClient
);
}
async init(): Promise<void> {
@ -159,5 +167,15 @@ export default class AppContext implements IAppContext {
request as SendNotificationRequest
);
});
this.subscribeToTshdEvent(
'sendPendingHeadlessAuthentication',
({ request, onCancelled }) => {
return this.headlessAuthenticationService.sendPendingHeadlessAuthentication(
request as SendPendingHeadlessAuthenticationRequest,
onCancelled
);
}
);
}
}

View file

@ -0,0 +1,59 @@
/**
* 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 { SendPendingHeadlessAuthenticationRequest } from 'teleterm/services/tshdEvents';
import { MainProcessClient } from 'teleterm/types';
import { ModalsService } from 'teleterm/ui/services/modals';
import type * as types from 'teleterm/services/tshd/types';
export class HeadlessAuthenticationService {
constructor(
private mainProcessClient: MainProcessClient,
private modalsService: ModalsService,
private tshClient: types.TshClient
) {}
sendPendingHeadlessAuthentication(
request: SendPendingHeadlessAuthenticationRequest,
onRequestCancelled: (callback: () => void) => void
): Promise<void> {
this.mainProcessClient.forceFocusWindow();
return new Promise(resolve => {
const { closeDialog } = this.modalsService.openImportantDialog({
kind: 'headless-authn',
rootClusterUri: request.rootClusterUri,
headlessAuthenticationId: request.headlessAuthenticationId,
headlessAuthenticationClientIp: request.headlessAuthenticationClientIp,
onSuccess: () => resolve(),
onCancel: () => resolve(),
});
onRequestCancelled(closeDialog);
});
}
async updateHeadlessAuthenticationState(
params: types.UpdateHeadlessAuthenticationStateParams,
abortSignal: types.TshAbortSignal
): Promise<void> {
return this.tshClient.updateHeadlessAuthenticationState(
params,
abortSignal
);
}
}

View file

@ -193,6 +193,15 @@ export interface DialogResourceSearchErrors {
onCancel: () => void;
}
export interface DialogHeadlessAuthentication {
kind: 'headless-authn';
rootClusterUri: RootClusterUri;
headlessAuthenticationId: string;
headlessAuthenticationClientIp: string;
onSuccess(): void;
onCancel(): void;
}
export type Dialog =
| DialogClusterConnect
| DialogClusterLogout
@ -200,4 +209,5 @@ export type Dialog =
| DialogUsageData
| DialogUserJobRole
| DialogResourceSearchErrors
| DialogHeadlessAuthentication
| DialogNone;

View file

@ -26,11 +26,12 @@ import { NotificationsService } from 'teleterm/ui/services/notifications';
import { ConnectionTrackerService } from 'teleterm/ui/services/connectionTracker';
import { FileTransferService } from 'teleterm/ui/services/fileTransferClient';
import { ResourcesService } from 'teleterm/ui/services/resources';
import { ReloginService } from 'teleterm/services/relogin';
import { TshdNotificationsService } from 'teleterm/services/tshdNotifications';
import { ReloginService } from 'teleterm/ui/services/relogin/reloginService';
import { TshdNotificationsService } from 'teleterm/ui/services/tshdNotifications/tshdNotificationService';
import { UsageService } from 'teleterm/ui/services/usage';
import { ConfigService } from 'teleterm/services/config';
import { ConnectMyComputerService } from 'teleterm/ui/services/connectMyComputer';
import { HeadlessAuthenticationService } from 'teleterm/ui/services/headlessAuthn/headlessAuthnService';
export interface IAppContext {
clustersService: ClustersService;
@ -51,6 +52,7 @@ export interface IAppContext {
usageService: UsageService;
configService: ConfigService;
connectMyComputerService: ConnectMyComputerService;
headlessAuthenticationService: HeadlessAuthenticationService;
init(): Promise<void>;
}