mirror of
https://github.com/gravitational/teleport
synced 2024-10-19 16:53:57 +00:00
Implement headless watcher approval logic in the Electron App. (#29097)
This commit is contained in:
parent
6fb9f08108
commit
29ff71ef09
61
lib/cache/collections.go
vendored
61
lib/cache/collections.go
vendored
|
@ -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{}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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';
|
17
web/packages/teleterm/src/ui/HeadlessAuthn/index.ts
Normal file
17
web/packages/teleterm/src/ui/HeadlessAuthn/index.ts
Normal 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';
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue