mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
* Remove lib/web dependency from tsh Refactors the terminal stream code in lib/web into a subpackage so that tsh no longer depends on the entire lib/web package. * Remove lib/auth dependency from tsh Refactors invalid credentials errors and helpers into lib/auth/authclient so that tsh no longer imports lib/auth directly. * fix find-replace typos
4925 lines
176 KiB
Go
4925 lines
176 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2023 Gravitational, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
// Package web implements web proxy handler that provides
|
|
// web interface to view and connect to teleport nodes
|
|
package web
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/safetext/shsprintf"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/gravitational/oxy/ratelimit"
|
|
"github.com/gravitational/roundtrip"
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/julienschmidt/httprouter"
|
|
"github.com/sashabaranov/go-openai"
|
|
"github.com/sirupsen/logrus"
|
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
|
oteltrace "go.opentelemetry.io/otel/trace"
|
|
tracepb "go.opentelemetry.io/proto/otlp/trace/v1"
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/mod/semver"
|
|
"golang.org/x/time/rate"
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
"github.com/gravitational/teleport"
|
|
apiclient "github.com/gravitational/teleport/api/client"
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/client/webclient"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
apidefaults "github.com/gravitational/teleport/api/defaults"
|
|
notificationsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/notifications/v1"
|
|
"github.com/gravitational/teleport/api/mfa"
|
|
apitracing "github.com/gravitational/teleport/api/observability/tracing"
|
|
"github.com/gravitational/teleport/api/types"
|
|
apievents "github.com/gravitational/teleport/api/types/events"
|
|
"github.com/gravitational/teleport/api/types/installers"
|
|
"github.com/gravitational/teleport/api/utils/keys"
|
|
apisshutils "github.com/gravitational/teleport/api/utils/sshutils"
|
|
"github.com/gravitational/teleport/lib/auth"
|
|
"github.com/gravitational/teleport/lib/auth/authclient"
|
|
"github.com/gravitational/teleport/lib/auth/state"
|
|
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
|
|
"github.com/gravitational/teleport/lib/authz"
|
|
"github.com/gravitational/teleport/lib/automaticupgrades"
|
|
"github.com/gravitational/teleport/lib/client"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
dtconfig "github.com/gravitational/teleport/lib/devicetrust/config"
|
|
"github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/httplib"
|
|
"github.com/gravitational/teleport/lib/httplib/csrf"
|
|
samlidp "github.com/gravitational/teleport/lib/idp/saml"
|
|
"github.com/gravitational/teleport/lib/jwt"
|
|
"github.com/gravitational/teleport/lib/limiter"
|
|
"github.com/gravitational/teleport/lib/modules"
|
|
"github.com/gravitational/teleport/lib/multiplexer"
|
|
"github.com/gravitational/teleport/lib/observability/tracing"
|
|
"github.com/gravitational/teleport/lib/plugin"
|
|
"github.com/gravitational/teleport/lib/proxy"
|
|
"github.com/gravitational/teleport/lib/reversetunnelclient"
|
|
"github.com/gravitational/teleport/lib/secret"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/session"
|
|
"github.com/gravitational/teleport/lib/srv/desktop/tdp"
|
|
"github.com/gravitational/teleport/lib/tlsca"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
"github.com/gravitational/teleport/lib/web/app"
|
|
websession "github.com/gravitational/teleport/lib/web/session"
|
|
"github.com/gravitational/teleport/lib/web/terminal"
|
|
"github.com/gravitational/teleport/lib/web/ui"
|
|
)
|
|
|
|
const (
|
|
// SSOLoginFailureMessage is a generic error message to avoid disclosing sensitive SSO failure messages.
|
|
SSOLoginFailureMessage = "Failed to login. Please check Teleport's log for more details."
|
|
|
|
// SSOLoginFailureInvalidRedirect is a slightly specific error message for
|
|
// SSO failures related to the use of an invalid or disallowed login
|
|
// callback URL in tsh login.
|
|
SSOLoginFailureInvalidRedirect = "Failed to login due to a disallowed callback URL. Please check Teleport's log for more details."
|
|
|
|
// assistantTokensPerHour defines how many assistant rate limiter tokens are replenished every hour.
|
|
assistantTokensPerHour = 140
|
|
// assistantLimiterRate is the rate (in tokens per second)
|
|
// at which tokens for the assistant rate limiter are replenished
|
|
assistantLimiterRate = rate.Limit(assistantTokensPerHour / float64(time.Hour/time.Second))
|
|
// assistantLimiterCapacity is the total capacity of the token bucket for the assistant rate limiter.
|
|
// The bucket starts full, prefilled for a week.
|
|
assistantLimiterCapacity = assistantTokensPerHour * 24 * 7
|
|
// webUIFlowLabelKey is a label that may be added to resources
|
|
// created via the web UI, indicating which flow the resource was created on.
|
|
// This label is used for enhancing UX in the web app, by showing icons related,
|
|
// to the workflow it was added, or providing unique features to those resources.
|
|
// Example values:
|
|
// - github-actions-ssh: indicates that the resource was added via the Bot GitHub Actions SSH flow
|
|
webUIFlowLabelKey = "teleport.internal/ui-flow"
|
|
)
|
|
|
|
// healthCheckAppServerFunc defines a function used to perform a health check
|
|
// to AppServer that can handle application requests (based on cluster name and
|
|
// public address).
|
|
type healthCheckAppServerFunc func(ctx context.Context, publicAddr string, clusterName string) error
|
|
|
|
// Handler is HTTP web proxy handler
|
|
type Handler struct {
|
|
log logrus.FieldLogger
|
|
|
|
sync.Mutex
|
|
httprouter.Router
|
|
cfg Config
|
|
auth *sessionCache
|
|
sessionStreamPollPeriod time.Duration
|
|
clock clockwork.Clock
|
|
limiter *limiter.RateLimiter
|
|
highLimiter *limiter.RateLimiter
|
|
// assistantLimiter limits the amount of tokens that can be consumed
|
|
// by OpenAI API calls when using a shared key.
|
|
// golang.org/x/time/rate is used, as the oxy ratelimiter
|
|
// is quite tightly tied to individual http.Requests,
|
|
// and instead we want to consume arbitrary amounts of tokens.
|
|
assistantLimiter *rate.Limiter
|
|
healthCheckAppServer healthCheckAppServerFunc
|
|
// sshPort specifies the SSH proxy port extracted
|
|
// from configuration
|
|
sshPort string
|
|
|
|
// userConns tracks amount of current active connections with user certificates.
|
|
userConns atomic.Int32
|
|
|
|
// ClusterFeatures contain flags for supported and unsupported features.
|
|
// Note: This field can become stale since it's only set on initial proxy
|
|
// startup. To get the latest feature flags you'll need to ping from the
|
|
// auth server.
|
|
// https://github.com/gravitational/teleport/issues/39161
|
|
ClusterFeatures proto.Features
|
|
|
|
// nodeWatcher is a services.NodeWatcher used by Assist to lookup nodes from
|
|
// the proxy's cache and get nodes in real time.
|
|
nodeWatcher *services.NodeWatcher
|
|
|
|
// tracer is used to create spans.
|
|
tracer oteltrace.Tracer
|
|
|
|
// wsIODeadline is used to set a deadline for receiving a message from
|
|
// an authenticated websocket so unauthenticated sockets dont get left
|
|
// open.
|
|
wsIODeadline time.Duration
|
|
|
|
// withheldMessages is a list of any messages that came from the browser which were
|
|
// withheld while the user was performing MFA.
|
|
withheldMessages []tdp.Message
|
|
}
|
|
|
|
// HandlerOption is a functional argument - an option that can be passed
|
|
// to NewHandler function
|
|
type HandlerOption func(h *Handler) error
|
|
|
|
// SetSessionStreamPollPeriod sets polling period for session streams
|
|
func SetSessionStreamPollPeriod(period time.Duration) HandlerOption {
|
|
return func(h *Handler) error {
|
|
if period < 0 {
|
|
return trace.BadParameter("period should be non zero")
|
|
}
|
|
h.sessionStreamPollPeriod = period
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// SetClock sets the clock on a handler
|
|
func SetClock(clock clockwork.Clock) HandlerOption {
|
|
return func(h *Handler) error {
|
|
h.clock = clock
|
|
return nil
|
|
}
|
|
}
|
|
|
|
type proxySettingsGetter interface {
|
|
GetProxySettings(ctx context.Context) (*webclient.ProxySettings, error)
|
|
}
|
|
|
|
// PresenceChecker is a function that executes an mfa prompt to enforce
|
|
// that a user is present.
|
|
type PresenceChecker = func(ctx context.Context, term io.Writer, maintainer client.PresenceMaintainer, sessionID string, mfaPrompt mfa.Prompt, opts ...client.PresenceOption) error
|
|
|
|
// Config represents web handler configuration parameters
|
|
type Config struct {
|
|
// PluginRegistry handles plugin registration
|
|
PluginRegistry plugin.Registry
|
|
// Proxy is a reverse tunnel proxy that handles connections
|
|
// to local cluster or remote clusters using unified interface
|
|
Proxy reversetunnelclient.Tunnel
|
|
// AuthServers is a list of auth servers this proxy talks to
|
|
AuthServers utils.NetAddr
|
|
// DomainName is a domain name served by web handler
|
|
DomainName string
|
|
// ProxyClient is a client that authenticated as proxy
|
|
ProxyClient authclient.ClientI
|
|
// ProxySSHAddr points to the SSH address of the proxy
|
|
ProxySSHAddr utils.NetAddr
|
|
// ProxyKubeAddr points to the Kube address of the proxy
|
|
ProxyKubeAddr utils.NetAddr
|
|
// ProxyWebAddr points to the web (HTTPS) address of the proxy
|
|
ProxyWebAddr utils.NetAddr
|
|
// ProxyPublicAddr contains web proxy public addresses.
|
|
ProxyPublicAddrs []utils.NetAddr
|
|
// GetProxyIdentity returns the identity of the proxy.
|
|
GetProxyIdentity func() (*state.Identity, error)
|
|
// CipherSuites is the list of cipher suites Teleport suppports.
|
|
CipherSuites []uint16
|
|
|
|
// FIPS mode means Teleport started in a FedRAMP/FIPS 140-2 compliant
|
|
// configuration.
|
|
FIPS bool
|
|
|
|
// AccessPoint holds a cache to the Auth Server.
|
|
AccessPoint authclient.ProxyAccessPoint
|
|
|
|
// Emitter is event emitter
|
|
Emitter apievents.Emitter
|
|
|
|
// HostUUID is the UUID of this process.
|
|
HostUUID string
|
|
|
|
// Context is used to signal process exit.
|
|
Context context.Context
|
|
|
|
// StaticFS optionally specifies the HTTP file system to use.
|
|
// Enables web UI if set.
|
|
StaticFS http.FileSystem
|
|
|
|
// CachedSessionLingeringThreshold specifies the time the session will linger
|
|
// in the cache before getting purged after it has expired.
|
|
// Defaults to cachedSessionLingeringThreshold if unspecified.
|
|
CachedSessionLingeringThreshold *time.Duration
|
|
|
|
// ClusterFeatures contains flags for supported/unsupported features.
|
|
ClusterFeatures proto.Features
|
|
|
|
// ProxySettings allows fetching the current proxy settings.
|
|
ProxySettings proxySettingsGetter
|
|
|
|
// MinimalReverseTunnelRoutesOnly mode handles only the endpoints required for
|
|
// a reverse tunnel agent to establish a connection.
|
|
MinimalReverseTunnelRoutesOnly bool
|
|
|
|
// PublicProxyAddr is used to template the public proxy address
|
|
// into the installer script responses
|
|
PublicProxyAddr string
|
|
|
|
// ALPNHandler is the ALPN connection handler for handling upgraded ALPN
|
|
// connection through a HTTP upgrade call.
|
|
ALPNHandler ConnectionHandler
|
|
|
|
// TraceClient is used to forward spans to the upstream collector for the UI
|
|
TraceClient otlptrace.Client
|
|
|
|
// Router is used to route ssh sessions to hosts
|
|
Router *proxy.Router
|
|
|
|
// SessionControl is used to determine if users are
|
|
// allowed to spawn new sessions
|
|
SessionControl SessionController
|
|
|
|
// PROXYSigner is used to sign PROXY header and securely propagate client IP information
|
|
PROXYSigner multiplexer.PROXYHeaderSigner
|
|
|
|
// TracerProvider generates tracers to create spans with
|
|
TracerProvider oteltrace.TracerProvider
|
|
|
|
// HealthCheckAppServer is a function that checks if the proxy can handle
|
|
// application requests.
|
|
HealthCheckAppServer healthCheckAppServerFunc
|
|
|
|
// UI provides config options for the web UI
|
|
UI webclient.UIConfig
|
|
|
|
// OpenAIConfig provides config options for the OpenAI integration.
|
|
OpenAIConfig *openai.ClientConfig
|
|
|
|
// NodeWatcher is a services.NodeWatcher used by Assist to lookup nodes from
|
|
// the proxy's cache and get nodes in real time.
|
|
NodeWatcher *services.NodeWatcher
|
|
|
|
// PresenceChecker periodically runs the mfa ceremony for moderated
|
|
// sessions.
|
|
PresenceChecker PresenceChecker
|
|
|
|
// AccessGraphAddr is the address of the Access Graph service GRPC API
|
|
AccessGraphAddr utils.NetAddr
|
|
|
|
// AutomaticUpgradesChannels is a map of all version channels used by the
|
|
// proxy built-in version server to retrieve target versions. This is part
|
|
// of the automatic upgrades.
|
|
AutomaticUpgradesChannels automaticupgrades.Channels
|
|
|
|
// IntegrationAppHandler handles App Access requests which use an Integration.
|
|
IntegrationAppHandler app.ServerHandler
|
|
}
|
|
|
|
// SetDefaults ensures proper default values are set if
|
|
// not provided.
|
|
func (c *Config) SetDefaults() {
|
|
c.ProxyClient = auth.WithGithubConnectorConversions(c.ProxyClient)
|
|
|
|
if c.TracerProvider == nil {
|
|
c.TracerProvider = tracing.NoopProvider()
|
|
}
|
|
|
|
if c.PresenceChecker == nil {
|
|
c.PresenceChecker = client.RunPresenceTask
|
|
}
|
|
}
|
|
|
|
type APIHandler struct {
|
|
handler *Handler
|
|
|
|
// appHandler is a http.Handler to forward requests to applications.
|
|
appHandler *app.Handler
|
|
}
|
|
|
|
// ConnectionHandler defines a function for serving incoming connections.
|
|
type ConnectionHandler func(ctx context.Context, conn net.Conn) error
|
|
|
|
// Check if this request should be forwarded to an application handler to
|
|
// be handled by the UI and handle the request appropriately.
|
|
func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// If the request is either to the fragment authentication endpoint or if the
|
|
// request has a session cookie or a client cert, forward to
|
|
// application handlers. If the request is requesting a
|
|
// FQDN that is not of the proxy, redirect to application launcher.
|
|
if h.appHandler != nil && (app.HasFragment(r) || app.HasSessionCookie(r) || app.HasClientCert(r)) {
|
|
h.appHandler.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Only try to redirect if the handler is serving the full Web API.
|
|
if !h.handler.cfg.MinimalReverseTunnelRoutesOnly {
|
|
if redir, ok := app.HasName(r, h.handler.cfg.ProxyPublicAddrs); ok {
|
|
http.Redirect(w, r, redir, http.StatusFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Serve the Web UI.
|
|
h.handler.ServeHTTP(w, r)
|
|
}
|
|
|
|
// HandleConnection handles connections from plain TCP applications.
|
|
func (h *APIHandler) HandleConnection(ctx context.Context, conn net.Conn) error {
|
|
return h.appHandler.HandleConnection(ctx, conn)
|
|
}
|
|
|
|
func (h *APIHandler) Close() error {
|
|
return h.handler.Close()
|
|
}
|
|
|
|
// NewHandler returns a new instance of web proxy handler
|
|
func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) {
|
|
const apiPrefix = "/" + teleport.WebAPIVersion
|
|
|
|
cfg.SetDefaults()
|
|
|
|
h := &Handler{
|
|
cfg: cfg,
|
|
log: newPackageLogger(),
|
|
clock: clockwork.NewRealClock(),
|
|
ClusterFeatures: cfg.ClusterFeatures,
|
|
healthCheckAppServer: cfg.HealthCheckAppServer,
|
|
tracer: cfg.TracerProvider.Tracer(teleport.ComponentWeb),
|
|
wsIODeadline: wsIODeadline,
|
|
}
|
|
|
|
// Check for self-hosted vs Cloud.
|
|
// TODO(justinas): this needs to be modified when we allow user-supplied API keys in Cloud
|
|
if cfg.ClusterFeatures.GetCloud() {
|
|
h.assistantLimiter = rate.NewLimiter(assistantLimiterRate, assistantLimiterCapacity)
|
|
} else {
|
|
// Set up a limiter with "infinite limit", the "burst" parameter is ignored
|
|
h.assistantLimiter = rate.NewLimiter(rate.Inf, 0)
|
|
}
|
|
|
|
if automaticUpgrades(cfg.ClusterFeatures) && h.cfg.AutomaticUpgradesChannels == nil {
|
|
h.cfg.AutomaticUpgradesChannels = automaticupgrades.Channels{}
|
|
}
|
|
|
|
// for properly handling url-encoded parameter values.
|
|
h.UseRawPath = true
|
|
|
|
for _, o := range opts {
|
|
if err := o(h); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
sessionLingeringThreshold := cachedSessionLingeringThreshold
|
|
if cfg.CachedSessionLingeringThreshold != nil {
|
|
sessionLingeringThreshold = *cfg.CachedSessionLingeringThreshold
|
|
}
|
|
|
|
sessionCache, err := newSessionCache(h.cfg.Context, sessionCacheOptions{
|
|
proxyClient: cfg.ProxyClient,
|
|
accessPoint: cfg.AccessPoint,
|
|
servers: []utils.NetAddr{cfg.AuthServers},
|
|
cipherSuites: cfg.CipherSuites,
|
|
clock: h.clock,
|
|
sessionLingeringThreshold: sessionLingeringThreshold,
|
|
proxySigner: cfg.PROXYSigner,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
h.auth = sessionCache
|
|
sshPortValue := strconv.Itoa(defaults.SSHProxyListenPort)
|
|
if cfg.ProxySSHAddr.String() != "" {
|
|
_, sshPort, err := net.SplitHostPort(cfg.ProxySSHAddr.String())
|
|
if err != nil {
|
|
h.log.WithError(err).Warnf("Invalid SSH proxy address %q, will use default port %v.",
|
|
cfg.ProxySSHAddr.String(), defaults.SSHProxyListenPort)
|
|
} else {
|
|
sshPortValue = sshPort
|
|
}
|
|
}
|
|
|
|
h.sshPort = sshPortValue
|
|
|
|
// rateLimiter is used to limit unauthenticated challenge generation for
|
|
// passwordless and for unauthenticated metrics.
|
|
h.limiter, err = limiter.NewRateLimiter(limiter.Config{
|
|
Rates: []limiter.Rate{
|
|
{
|
|
Period: defaults.LimiterPeriod,
|
|
Average: defaults.LimiterAverage,
|
|
Burst: defaults.LimiterBurst,
|
|
},
|
|
},
|
|
MaxConnections: defaults.LimiterMaxConnections,
|
|
MaxNumberOfUsers: defaults.LimiterMaxConcurrentUsers,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
// highLimiter is used for endpoints which are only CPU constrained and require high request rates
|
|
h.highLimiter, err = limiter.NewRateLimiter(limiter.Config{
|
|
Rates: []limiter.Rate{
|
|
{
|
|
Period: defaults.LimiterHighPeriod,
|
|
Average: defaults.LimiterHighAverage,
|
|
Burst: defaults.LimiterHighBurst,
|
|
},
|
|
},
|
|
MaxConnections: defaults.LimiterMaxConnections,
|
|
MaxNumberOfUsers: defaults.LimiterMaxConcurrentUsers,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if cfg.MinimalReverseTunnelRoutesOnly {
|
|
h.bindMinimalEndpoints()
|
|
} else {
|
|
h.bindDefaultEndpoints()
|
|
}
|
|
|
|
// serve the web UI from the embedded filesystem
|
|
var indexPage *template.Template
|
|
// we will set our etag based on the teleport version and
|
|
// the webasset app hash if available. The version only will not
|
|
// suffice as it can cause incorrect caching for local development.
|
|
|
|
// The hash of the webasset app.js is used to ensure that builds at
|
|
// different times or different OSes will be the same and not cause
|
|
// cache invalidation for production users. For example, using a timestamp
|
|
// at build time would cause different OS builds to be different, and timestamps
|
|
// at process start would mean multiple proxies would serving different etags)
|
|
etag := fmt.Sprintf("W/%q", teleport.Version)
|
|
if cfg.StaticFS != nil {
|
|
index, err := cfg.StaticFS.Open("/index.html")
|
|
if err != nil {
|
|
h.log.WithError(err).Error("Failed to open index file.")
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
defer index.Close()
|
|
indexContent, err := io.ReadAll(index)
|
|
if err != nil {
|
|
return nil, trace.ConvertSystemError(err)
|
|
}
|
|
indexPage, err = template.New("index").Parse(string(indexContent))
|
|
if err != nil {
|
|
return nil, trace.BadParameter("failed parsing index.html template: %v", err)
|
|
}
|
|
|
|
h.Handle("GET", "/robots.txt", httplib.MakeHandler(serveRobotsTxt))
|
|
h.Handle("GET", "/web/config.js", h.WithUnauthenticatedLimiter(h.getWebConfig))
|
|
|
|
etagFromAppHash, err := readEtagFromAppHash(cfg.StaticFS)
|
|
if err != nil {
|
|
h.log.WithError(err).Error("Could not read apphash from embedded webassets. Using version only as ETag for Web UI assets.")
|
|
} else {
|
|
etag = etagFromAppHash
|
|
}
|
|
}
|
|
|
|
if cfg.NodeWatcher != nil {
|
|
h.nodeWatcher = cfg.NodeWatcher
|
|
}
|
|
|
|
routingHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// ensure security headers are set for all responses
|
|
httplib.SetDefaultSecurityHeaders(w.Header())
|
|
|
|
// request is going to the API?
|
|
if strings.HasPrefix(r.URL.Path, apiPrefix) {
|
|
http.StripPrefix(apiPrefix, h).ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// request is going to the web UI
|
|
if cfg.StaticFS == nil {
|
|
w.WriteHeader(http.StatusNotImplemented)
|
|
return
|
|
}
|
|
|
|
// redirect to "/web" when someone hits "/"
|
|
if r.URL.Path == "/" {
|
|
app.SetRedirectPageHeaders(w.Header(), "")
|
|
http.Redirect(w, r, "/web", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
// serve Web UI:
|
|
if strings.HasPrefix(r.URL.Path, "/web/app") {
|
|
|
|
// Check if the incoming request wants to check the version
|
|
// and if the version has not changed, return a Not Modified response
|
|
if match := r.Header.Get("If-None-Match"); match == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
fs := http.FileServer(cfg.StaticFS)
|
|
|
|
fs = makeGzipHandler(fs)
|
|
fs = makeCacheHandler(fs, etag)
|
|
|
|
http.StripPrefix("/web", fs).ServeHTTP(w, r)
|
|
} else if strings.HasPrefix(r.URL.Path, "/web/") || r.URL.Path == "/web" {
|
|
csrfToken, err := csrf.AddCSRFProtection(w, r)
|
|
if err != nil {
|
|
h.log.WithError(err).Warn("Failed to generate CSRF token.")
|
|
}
|
|
|
|
session, err := h.authenticateWebSession(w, r)
|
|
if err != nil {
|
|
h.log.Debugf("Could not authenticate: %v", err)
|
|
}
|
|
session.XCSRF = csrfToken
|
|
|
|
httplib.SetNoCacheHeaders(w.Header())
|
|
httplib.SetIndexContentSecurityPolicy(w.Header(), cfg.ClusterFeatures, r.URL.Path)
|
|
|
|
if err := indexPage.Execute(w, session); err != nil {
|
|
h.log.WithError(err).Error("Failed to execute index page template.")
|
|
}
|
|
} else {
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
|
|
h.NotFound = routingHandler
|
|
|
|
if cfg.PluginRegistry != nil {
|
|
if err := cfg.PluginRegistry.RegisterProxyWebHandlers(h); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
resp, err := h.cfg.ProxySettings.GetProxySettings(cfg.Context)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// Create application specific handler. This handler handles sessions and
|
|
// forwarding for application access.
|
|
var appHandler *app.Handler
|
|
if !cfg.MinimalReverseTunnelRoutesOnly {
|
|
appHandler, err = app.NewHandler(cfg.Context, &app.HandlerConfig{
|
|
Clock: h.clock,
|
|
AuthClient: cfg.ProxyClient,
|
|
AccessPoint: cfg.AccessPoint,
|
|
ProxyClient: cfg.Proxy,
|
|
CipherSuites: cfg.CipherSuites,
|
|
ProxyPublicAddrs: cfg.ProxyPublicAddrs,
|
|
WebPublicAddr: resp.SSH.PublicAddr,
|
|
IntegrationAppHandler: cfg.IntegrationAppHandler,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if h.healthCheckAppServer == nil {
|
|
h.healthCheckAppServer = appHandler.HealthCheckAppServer
|
|
}
|
|
}
|
|
|
|
return &APIHandler{
|
|
handler: h,
|
|
appHandler: appHandler,
|
|
}, nil
|
|
}
|
|
|
|
type webSession struct {
|
|
Session string
|
|
XCSRF string
|
|
}
|
|
|
|
func (h *Handler) authenticateWebSession(w http.ResponseWriter, r *http.Request) (webSession, error) {
|
|
ctx, err := h.AuthenticateRequest(w, r, false)
|
|
if err != nil {
|
|
return webSession{}, trace.Wrap(err)
|
|
}
|
|
resp, err := newSessionResponse(ctx)
|
|
if err != nil {
|
|
return webSession{}, trace.Wrap(err)
|
|
}
|
|
out, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return webSession{}, trace.Wrap(err)
|
|
}
|
|
return webSession{
|
|
Session: base64.StdEncoding.EncodeToString(out),
|
|
}, nil
|
|
}
|
|
|
|
// bindMinimalEndpoints binds only the endpoints required for a reverse tunnel
|
|
// agent to establish a connection.
|
|
func (h *Handler) bindMinimalEndpoints() {
|
|
// find is like ping, but is faster because it is optimized for servers
|
|
// and does not fetch the data that servers don't need, e.g.
|
|
// OIDC connectors and auth preferences
|
|
// Note that find is a unique endpoint that requires high request rates
|
|
// sometimes through NATs and thus should not be rate limited by IP.
|
|
h.GET("/webapi/find", httplib.MakeHandler(h.find))
|
|
// Issue host credentials.
|
|
h.POST("/webapi/host/credentials", h.WithUnauthenticatedHighLimiter(h.hostCredentials))
|
|
}
|
|
|
|
// bindDefaultEndpoints binds the default endpoints for the web API.
|
|
func (h *Handler) bindDefaultEndpoints() {
|
|
h.bindMinimalEndpoints()
|
|
|
|
// ping endpoint is used to check if the server is up. the /webapi/ping
|
|
// endpoint returns the default authentication method and configuration that
|
|
// the server supports. the /webapi/ping/:connector endpoint can be used to
|
|
// query the authentication configuration for a specific connector.
|
|
h.GET("/webapi/ping", h.WithUnauthenticatedHighLimiter(h.ping))
|
|
h.GET("/webapi/ping/:connector", h.WithUnauthenticatedHighLimiter(h.pingWithConnector))
|
|
|
|
// Unauthenticated access to JWT public keys.
|
|
h.GET("/.well-known/jwks.json", h.WithUnauthenticatedHighLimiter(h.wellKnownJWKS))
|
|
|
|
// Unauthenticated access to the message of the day
|
|
h.GET("/webapi/motd", h.WithHighLimiter(h.motd))
|
|
|
|
// Unauthenticated access to retrieving the script used to install
|
|
// Teleport
|
|
h.GET("/webapi/scripts/installer/:name", h.WithLimiter(h.installer))
|
|
|
|
// desktop access configuration scripts
|
|
h.GET("/webapi/scripts/desktop-access/install-ad-ds.ps1", h.WithLimiter(h.desktopAccessScriptInstallADDSHandle))
|
|
h.GET("/webapi/scripts/desktop-access/install-ad-cs.ps1", h.WithLimiter(h.desktopAccessScriptInstallADCSHandle))
|
|
h.GET("/webapi/scripts/desktop-access/configure/:token/configure-ad.ps1", h.WithLimiter(h.desktopAccessScriptConfigureHandle))
|
|
|
|
// Forwards traces to the configured upstream collector
|
|
h.POST("/webapi/traces", h.WithAuth(h.traces))
|
|
|
|
// App sessions
|
|
h.POST("/webapi/sessions/app", h.WithAuth(h.createAppSession))
|
|
|
|
// Web sessions
|
|
h.POST("/webapi/sessions/web", httplib.WithCSRFProtection(h.WithLimiterHandlerFunc(h.createWebSession)))
|
|
h.DELETE("/webapi/sessions/web", h.WithAuth(h.deleteWebSession))
|
|
h.POST("/webapi/sessions/web/renew", h.WithAuth(h.renewWebSession))
|
|
h.POST("/webapi/users", h.WithAuth(h.createUserHandle))
|
|
h.PUT("/webapi/users", h.WithAuth(h.updateUserHandle))
|
|
h.GET("/webapi/users", h.WithAuth(h.getUsersHandle))
|
|
h.DELETE("/webapi/users/:username", h.WithAuth(h.deleteUserHandle))
|
|
|
|
// We have an overlap route here, please see godoc of handleGetUserOrResetToken
|
|
// h.GET("/webapi/users/:username", h.WithAuth(h.getUserHandle))
|
|
// h.GET("/webapi/users/password/token/:token", h.WithLimiter(h.getResetPasswordTokenHandle))
|
|
h.GET("/webapi/users/*wildcard", h.handleGetUserOrResetToken)
|
|
|
|
h.PUT("/webapi/users/password/token", httplib.WithCSRFProtection(h.changeUserAuthentication))
|
|
h.PUT("/webapi/users/password", h.WithAuth(h.changePassword))
|
|
h.POST("/webapi/users/password/token", h.WithAuth(h.createResetPasswordToken))
|
|
h.POST("/webapi/users/privilege/token", h.WithAuth(h.createPrivilegeTokenHandle))
|
|
|
|
// Issues SSH temp certificates based on 2FA access creds
|
|
h.POST("/webapi/ssh/certs", h.WithUnauthenticatedLimiter(h.createSSHCert))
|
|
|
|
// list available sites
|
|
h.GET("/webapi/sites", h.WithAuth(h.getClusters))
|
|
|
|
// Site specific API
|
|
|
|
// get namespaces
|
|
h.GET("/webapi/sites/:site/namespaces", h.WithClusterAuth(h.getSiteNamespaces))
|
|
|
|
// get unified resources
|
|
h.GET("/webapi/sites/:site/resources", h.WithClusterAuth(h.clusterUnifiedResourcesGet))
|
|
|
|
// get nodes
|
|
h.GET("/webapi/sites/:site/nodes", h.WithClusterAuth(h.clusterNodesGet))
|
|
h.POST("/webapi/sites/:site/nodes", h.WithClusterAuth(h.handleNodeCreate))
|
|
|
|
// Get applications.
|
|
h.GET("/webapi/sites/:site/apps", h.WithClusterAuth(h.clusterAppsGet))
|
|
|
|
// get login alerts
|
|
h.GET("/webapi/sites/:site/alerts", h.WithClusterAuth(h.clusterLoginAlertsGet))
|
|
|
|
// lock interactions
|
|
h.GET("/webapi/sites/:site/locks", h.WithClusterAuth(h.getClusterLocks))
|
|
h.PUT("/webapi/sites/:site/locks", h.WithClusterAuth(h.createClusterLock))
|
|
h.DELETE("/webapi/sites/:site/locks/:uuid", h.WithClusterAuth(h.deleteClusterLock))
|
|
|
|
// active sessions handlers
|
|
// Deprecated: The connect/ws variant should be used instead.
|
|
// TODO(lxea): DELETE in v16
|
|
h.GET("/webapi/sites/:site/connect", h.WithClusterAuthWebSocket(false, h.siteNodeConnect)) // connect to an active session (via websocket)
|
|
h.GET("/webapi/sites/:site/connect/ws", h.WithClusterAuthWebSocket(true, h.siteNodeConnect)) // connect to an active session (via websocket, with auth over websocket)
|
|
h.GET("/webapi/sites/:site/sessions", h.WithClusterAuth(h.clusterActiveAndPendingSessionsGet)) // get list of active and pending sessions
|
|
|
|
h.GET("/webapi/sites/:site/kube/:clusterName/connect/ws", h.WithClusterAuthWebSocket(true, h.podConnect)) // connect to a pod with exec (via websocket, with auth over websocket)
|
|
|
|
// Audit events handlers.
|
|
h.GET("/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEvents)) // search site events
|
|
h.GET("/webapi/sites/:site/events/search/sessions", h.WithClusterAuth(h.clusterSearchSessionEvents)) // search site session events
|
|
h.GET("/webapi/sites/:site/ttyplayback/:sid", h.WithClusterAuth(h.ttyPlaybackHandle))
|
|
|
|
// TODO(zmb3): remove these endpoints when Assist is no longer using them
|
|
// (assist calls the proxy's web API, and the proxy uses an HTTP client to call auth's API)
|
|
h.GET("/webapi/sites/:site/sessions/:sid/events", h.WithClusterAuth(h.siteSessionEventsGet)) // get recorded session's timing information (from events)
|
|
h.GET("/webapi/sites/:site/sessions/:sid/stream", h.siteSessionStreamGet) // get recorded session's bytes (from events)
|
|
|
|
// scp file transfer
|
|
h.GET("/webapi/sites/:site/nodes/:server/:login/scp", h.WithClusterAuth(h.transferFile))
|
|
h.POST("/webapi/sites/:site/nodes/:server/:login/scp", h.WithClusterAuth(h.transferFile))
|
|
|
|
// Sign required files to set up mTLS using the db format.
|
|
h.POST("/webapi/sites/:site/sign/db", h.WithProvisionTokenAuth(h.signDatabaseCertificate))
|
|
|
|
// Returns the CA Certs
|
|
// Deprecated, use the `webapi/auth/export` endpoint.
|
|
// Returning other clusters (trusted cluster) CA certs would leak wether the TrustedCluster exists or not.
|
|
// Given that this is a public/unauthorized endpoint, we should refrain from exposing that kind of information.
|
|
h.GET("/webapi/sites/:site/auth/export", h.authExportPublic)
|
|
h.GET("/webapi/auth/export", h.authExportPublic)
|
|
|
|
// token generation
|
|
h.POST("/webapi/token", h.WithAuth(h.createTokenHandle))
|
|
|
|
// join scripts
|
|
h.GET("/scripts/:token/install-node.sh", h.WithLimiter(h.getNodeJoinScriptHandle))
|
|
h.GET("/scripts/:token/install-app.sh", h.WithLimiter(h.getAppJoinScriptHandle))
|
|
h.GET("/scripts/:token/install-database.sh", h.WithLimiter(h.getDatabaseJoinScriptHandle))
|
|
|
|
// Discovery installation script requires a query param to define the DiscoveryGroup:
|
|
// ?discoveryGroup=<group name>
|
|
h.GET("/scripts/:token/install-discovery.sh", h.WithLimiter(h.getDiscoveryJoinScriptHandle))
|
|
|
|
// web context
|
|
h.GET("/webapi/sites/:site/context", h.WithClusterAuth(h.getUserContext))
|
|
h.GET("/webapi/sites/:site/resources/check", h.WithClusterAuth(h.checkAccessToRegisteredResource))
|
|
|
|
// Database access handlers.
|
|
h.GET("/webapi/sites/:site/databases", h.WithClusterAuth(h.clusterDatabasesGet))
|
|
h.POST("/webapi/sites/:site/databases", h.WithClusterAuth(h.handleDatabaseCreate))
|
|
h.PUT("/webapi/sites/:site/databases/:database", h.WithClusterAuth(h.handleDatabaseUpdate))
|
|
h.GET("/webapi/sites/:site/databases/:database", h.WithClusterAuth(h.clusterDatabaseGet))
|
|
h.GET("/webapi/sites/:site/databases/:database/iam/policy", h.WithClusterAuth(h.handleDatabaseGetIAMPolicy))
|
|
h.GET("/webapi/scripts/databases/configure/sqlserver/:token/configure-ad.ps1", httplib.MakeHandler(h.sqlServerConfigureADScriptHandle))
|
|
|
|
// DatabaseService handlers
|
|
h.GET("/webapi/sites/:site/databaseservices", h.WithClusterAuth(h.clusterDatabaseServicesList))
|
|
|
|
// Kube access handlers.
|
|
h.GET("/webapi/sites/:site/kubernetes", h.WithClusterAuth(h.clusterKubesGet))
|
|
h.GET("/webapi/sites/:site/pods", h.WithClusterAuth(h.clusterKubePodsGet))
|
|
|
|
// Github connector handlers
|
|
h.GET("/webapi/github/login/web", h.WithRedirect(h.githubLoginWeb))
|
|
h.GET("/webapi/github/callback", h.WithMetaRedirect(h.githubCallback))
|
|
h.POST("/webapi/github/login/console", h.WithLimiter(h.githubLoginConsole))
|
|
|
|
// MFA public endpoints.
|
|
h.POST("/webapi/sites/:site/mfa/required", h.WithClusterAuth(h.isMFARequired))
|
|
h.POST("/webapi/mfa/login/begin", h.WithLimiter(h.mfaLoginBegin))
|
|
h.POST("/webapi/mfa/login/finish", h.WithLimiter(h.mfaLoginFinish))
|
|
h.POST("/webapi/mfa/login/finishsession", h.WithLimiter(h.mfaLoginFinishSession))
|
|
h.DELETE("/webapi/mfa/token/:token/devices/:devicename", h.WithLimiter(h.deleteMFADeviceWithTokenHandle))
|
|
h.GET("/webapi/mfa/token/:token/devices", h.WithLimiter(h.getMFADevicesWithTokenHandle))
|
|
h.POST("/webapi/mfa/token/:token/authenticatechallenge", h.WithLimiter(h.createAuthenticateChallengeWithTokenHandle))
|
|
h.POST("/webapi/mfa/token/:token/registerchallenge", h.WithLimiter(h.createRegisterChallengeWithTokenHandle))
|
|
|
|
// MFA private endpoints.
|
|
h.GET("/webapi/mfa/devices", h.WithAuth(h.getMFADevicesHandle))
|
|
h.POST("/webapi/mfa/authenticatechallenge", h.WithAuth(h.createAuthenticateChallengeHandle))
|
|
h.POST("/webapi/mfa/devices", h.WithAuth(h.addMFADeviceHandle))
|
|
// DEPRECATED in favor of mfa/authenticatechallenge.
|
|
// TODO(bl-nero): DELETE IN 17.0.0
|
|
h.POST("/webapi/mfa/authenticatechallenge/password", h.WithAuth(h.createAuthenticateChallengeWithPassword))
|
|
|
|
// Device Trust.
|
|
// Do not enforce bearer token for /webconfirm, it is called from outside the
|
|
// Web UI.
|
|
h.GET("/webapi/devices/webconfirm", h.WithAuthCookieAndCSRF(h.deviceWebConfirm))
|
|
|
|
// trusted clusters
|
|
h.POST("/webapi/trustedclusters/validate", h.WithUnauthenticatedLimiter(h.validateTrustedCluster))
|
|
|
|
// User Status (used by client to check if user session is valid)
|
|
h.GET("/webapi/user/status", h.WithAuth(h.getUserStatus))
|
|
|
|
h.GET("/webapi/roles", h.WithAuth(h.listRolesHandle))
|
|
h.POST("/webapi/roles", h.WithAuth(h.createRoleHandle))
|
|
h.PUT("/webapi/roles/:name", h.WithAuth(h.updateRoleHandle))
|
|
h.DELETE("/webapi/roles/:name", h.WithAuth(h.deleteRole))
|
|
h.GET("/webapi/presetroles", h.WithUnauthenticatedHighLimiter(h.getPresetRoles))
|
|
|
|
h.GET("/webapi/github", h.WithAuth(h.getGithubConnectorsHandle))
|
|
h.POST("/webapi/github", h.WithAuth(h.createGithubConnectorHandle))
|
|
h.PUT("/webapi/github/:name", h.WithAuth(h.updateGithubConnectorHandle))
|
|
h.DELETE("/webapi/github/:name", h.WithAuth(h.deleteGithubConnector))
|
|
|
|
h.GET("/webapi/trustedcluster", h.WithAuth(h.getTrustedClustersHandle))
|
|
h.POST("/webapi/trustedcluster", h.WithAuth(h.upsertTrustedClusterHandle))
|
|
h.PUT("/webapi/trustedcluster/:name", h.WithAuth(h.upsertTrustedClusterHandle))
|
|
h.DELETE("/webapi/trustedcluster/:name", h.WithAuth(h.deleteTrustedCluster))
|
|
|
|
h.GET("/webapi/apps/:fqdnHint", h.WithAuth(h.getAppFQDN))
|
|
h.GET("/webapi/apps/:fqdnHint/:clusterName/:publicAddr", h.WithAuth(h.getAppFQDN))
|
|
|
|
h.POST("/webapi/yaml/parse/:kind", h.WithAuth(h.yamlParse))
|
|
h.POST("/webapi/yaml/stringify/:kind", h.WithAuth(h.yamlStringify))
|
|
|
|
// Desktop access endpoints.
|
|
h.GET("/webapi/sites/:site/desktops", h.WithClusterAuth(h.clusterDesktopsGet))
|
|
h.GET("/webapi/sites/:site/desktopservices", h.WithClusterAuth(h.clusterDesktopServicesGet))
|
|
h.GET("/webapi/sites/:site/desktops/:desktopName", h.WithClusterAuth(h.getDesktopHandle))
|
|
// GET /webapi/sites/:site/desktops/:desktopName/connect?access_token=<bearer_token>&username=<username>&width=<width>&height=<height>
|
|
// Deprecated: The connect/ws variant should be used instead.
|
|
// TODO(lxea): DELETE in v16
|
|
h.GET("/webapi/sites/:site/desktops/:desktopName/connect", h.WithClusterAuthWebSocket(false, h.desktopConnectHandle))
|
|
// GET /webapi/sites/:site/desktops/:desktopName/connect?username=<username>&width=<width>&height=<height>
|
|
h.GET("/webapi/sites/:site/desktops/:desktopName/connect/ws", h.WithClusterAuthWebSocket(true, h.desktopConnectHandle))
|
|
// GET /webapi/sites/:site/desktopplayback/:sid?access_token=<bearer_token>
|
|
// Deprecated: The desktopplayback/ws variant should be used instead.
|
|
// TODO(lxea): DELETE in v16
|
|
h.GET("/webapi/sites/:site/desktopplayback/:sid", h.WithClusterAuthWebSocket(false, h.desktopPlaybackHandle))
|
|
// GET /webapi/sites/:site/desktopplayback/:sid/ws
|
|
h.GET("/webapi/sites/:site/desktopplayback/:sid/ws", h.WithClusterAuthWebSocket(true, h.desktopPlaybackHandle))
|
|
h.GET("/webapi/sites/:site/desktops/:desktopName/active", h.WithClusterAuth(h.desktopIsActive))
|
|
|
|
// GET a Connection Diagnostics by its name
|
|
h.GET("/webapi/sites/:site/diagnostics/connections/:connectionid", h.WithClusterAuth(h.getConnectionDiagnostic))
|
|
// Diagnose a Connection
|
|
h.POST("/webapi/sites/:site/diagnostics/connections", h.WithClusterAuth(h.diagnoseConnection))
|
|
|
|
// Integrations CRUD
|
|
h.GET("/webapi/sites/:site/integrations", h.WithClusterAuth(h.integrationsList))
|
|
h.POST("/webapi/sites/:site/integrations", h.WithClusterAuth(h.integrationsCreate))
|
|
h.GET("/webapi/sites/:site/integrations/:name", h.WithClusterAuth(h.integrationsGet))
|
|
h.PUT("/webapi/sites/:site/integrations/:name", h.WithClusterAuth(h.integrationsUpdate))
|
|
h.DELETE("/webapi/sites/:site/integrations/:name", h.WithClusterAuth(h.integrationsDelete))
|
|
|
|
// AWS OIDC Integration Actions
|
|
h.GET("/webapi/scripts/integrations/configure/awsoidc-idp.sh", h.WithLimiter(h.awsOIDCConfigureIdP))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/databases", h.WithClusterAuth(h.awsOIDCListDatabases))
|
|
h.GET("/webapi/scripts/integrations/configure/listdatabases-iam.sh", h.WithLimiter(h.awsOIDCConfigureListDatabasesIAM))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/deployservice", h.WithClusterAuth(h.awsOIDCDeployService))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/deploydatabaseservices", h.WithClusterAuth(h.awsOIDCDeployDatabaseServices))
|
|
h.GET("/webapi/scripts/integrations/configure/deployservice-iam.sh", h.WithLimiter(h.awsOIDCConfigureDeployServiceIAM))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/ec2", h.WithClusterAuth(h.awsOIDCListEC2))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/eksclusters", h.WithClusterAuth(h.awsOIDCListEKSClusters))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/enrolleksclusters", h.WithClusterAuth(h.awsOIDCEnrollEKSClusters))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/ec2ice", h.WithClusterAuth(h.awsOIDCListEC2ICE))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/deployec2ice", h.WithClusterAuth(h.awsOIDCDeployEC2ICE))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/securitygroups", h.WithClusterAuth(h.awsOIDCListSecurityGroups))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/requireddatabasesvpcs", h.WithClusterAuth(h.awsOIDCRequiredDatabasesVPCS))
|
|
h.GET("/webapi/scripts/integrations/configure/eice-iam.sh", h.WithLimiter(h.awsOIDCConfigureEICEIAM))
|
|
h.GET("/webapi/scripts/integrations/configure/eks-iam.sh", h.WithLimiter(h.awsOIDCConfigureEKSIAM))
|
|
h.GET("/webapi/scripts/integrations/configure/access-graph-cloud-sync-iam.sh", h.WithLimiter(h.accessGraphCloudSyncOIDC))
|
|
h.GET("/webapi/scripts/integrations/configure/aws-app-access-iam.sh", h.WithLimiter(h.awsOIDCConfigureAWSAppAccessIAM))
|
|
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/aws-app-access", h.WithClusterAuth(h.awsOIDCCreateAWSAppAccess))
|
|
h.GET("/webapi/scripts/integrations/configure/ec2-ssm-iam.sh", h.WithLimiter(h.awsOIDCConfigureEC2SSMIAM))
|
|
|
|
// SAML IDP integration endpoints
|
|
h.GET("/webapi/scripts/integrations/configure/gcp-workforce-saml.sh", h.WithLimiter(h.gcpWorkforceConfigScript))
|
|
|
|
// AWS OIDC Integration specific endpoints:
|
|
// Unauthenticated access to OpenID Configuration - used for AWS OIDC IdP integration
|
|
h.GET("/.well-known/openid-configuration", h.WithLimiter(h.openidConfiguration))
|
|
h.GET(OIDCJWKWURI, h.WithLimiter(h.jwksOIDC))
|
|
h.GET("/webapi/thumbprint", h.WithLimiter(h.thumbprint))
|
|
|
|
// DiscoveryConfig CRUD
|
|
h.GET("/webapi/sites/:site/discoveryconfig", h.WithClusterAuth(h.discoveryconfigList))
|
|
h.POST("/webapi/sites/:site/discoveryconfig", h.WithClusterAuth(h.discoveryconfigCreate))
|
|
h.GET("/webapi/sites/:site/discoveryconfig/:name", h.WithClusterAuth(h.discoveryconfigGet))
|
|
h.PUT("/webapi/sites/:site/discoveryconfig/:name", h.WithClusterAuth(h.discoveryconfigUpdate))
|
|
h.DELETE("/webapi/sites/:site/discoveryconfig/:name", h.WithClusterAuth(h.discoveryconfigDelete))
|
|
|
|
// Connection upgrades.
|
|
h.GET("/webapi/connectionupgrade", h.WithHighLimiter(h.connectionUpgrade))
|
|
|
|
// create user events.
|
|
h.POST("/webapi/precapture", h.WithUnauthenticatedLimiter(h.createPreUserEventHandle))
|
|
// create authenticated user events.
|
|
h.POST("/webapi/capture", h.WithAuth(h.createUserEventHandle))
|
|
|
|
h.GET("/webapi/headless/:headless_authentication_id", h.WithAuth(h.getHeadless))
|
|
h.PUT("/webapi/headless/:headless_authentication_id", h.WithAuth(h.putHeadlessState))
|
|
|
|
h.GET("/webapi/sites/:site/user-groups", h.WithClusterAuth(h.getUserGroups))
|
|
|
|
// WebSocket endpoint for the chat conversation, websocket auth
|
|
h.GET("/webapi/sites/:site/assistant/ws", h.WithClusterAuthWebSocket(true, h.assistant))
|
|
|
|
// Fetches the user's preferences
|
|
h.GET("/webapi/user/preferences", h.WithAuth(h.getUserPreferences))
|
|
|
|
// Updates the user's preferences
|
|
h.PUT("/webapi/user/preferences", h.WithAuth(h.updateUserPreferences))
|
|
|
|
// Fetches the user's cluster preferences.
|
|
h.GET("/webapi/user/preferences/:site", h.WithClusterAuth(h.getUserClusterPreferences))
|
|
|
|
// Updates the user's cluster preferences.
|
|
h.PUT("/webapi/user/preferences/:site", h.WithClusterAuth(h.updateUserClusterPreferences))
|
|
|
|
// Returns logins included in the Connect My Computer role of the user.
|
|
// Returns an empty list of logins if the user does not have a Connect My Computer role assigned.
|
|
h.GET("/webapi/connectmycomputer/logins", h.WithAuth(h.connectMyComputerLoginsList))
|
|
|
|
// Implements the agent version server.
|
|
// Channel can contain "/", hence the use of a catch-all parameter
|
|
h.GET("/webapi/automaticupgrades/channel/*request", h.WithUnauthenticatedHighLimiter(h.automaticUpgrades))
|
|
|
|
// GET Machine ID bot by name
|
|
h.GET("/webapi/sites/:site/machine-id/bot/:name", h.WithClusterAuth(h.getBot))
|
|
// GET Machine ID bots
|
|
h.GET("/webapi/sites/:site/machine-id/bot", h.WithClusterAuth(h.listBots))
|
|
// Create Machine ID bots
|
|
h.POST("/webapi/sites/:site/machine-id/bot", h.WithClusterAuth(h.createBot))
|
|
// Create bot join tokens
|
|
h.POST("/webapi/sites/:site/machine-id/token", h.WithClusterAuth(h.createBotJoinToken))
|
|
// PUT Machine ID bot by name
|
|
h.PUT("/webapi/sites/:site/machine-id/bot/:name", h.WithClusterAuth(h.updateBot))
|
|
// Delete Machine ID bot
|
|
h.DELETE("/webapi/sites/:site/machine-id/bot/:name", h.WithClusterAuth(h.deleteBot))
|
|
|
|
// GET a paginated list of notifications for a user
|
|
h.GET("/webapi/sites/:site/notifications", h.WithClusterAuth(h.notificationsGet))
|
|
// Upsert the timestamp of the latest notification that the user has seen.
|
|
h.PUT("/webapi/sites/:site/lastseennotification", h.WithClusterAuth(h.notificationsUpsertLastSeenTimestamp))
|
|
// Upsert a notification state when to mark a notification as read or hide it.
|
|
h.PUT("/webapi/sites/:site/notificationstate", h.WithClusterAuth(h.notificationsUpsertNotificationState))
|
|
}
|
|
|
|
// GetProxyClient returns authenticated auth server client
|
|
func (h *Handler) GetProxyClient() authclient.ClientI {
|
|
return h.cfg.ProxyClient
|
|
}
|
|
|
|
// GetProxyIdentity returns the identity of the proxy
|
|
func (h *Handler) GetProxyIdentity() (*state.Identity, error) {
|
|
if h.cfg.GetProxyIdentity == nil {
|
|
return nil, trace.BadParameter("GetProxyIdentity function is not set")
|
|
}
|
|
rsp, err := h.cfg.GetProxyIdentity()
|
|
return rsp, trace.Wrap(err)
|
|
}
|
|
|
|
// GetAccessPoint returns the caching access point.
|
|
func (h *Handler) GetAccessPoint() authclient.ProxyAccessPoint {
|
|
return h.cfg.AccessPoint
|
|
}
|
|
|
|
// Close closes associated session cache operations
|
|
func (h *Handler) Close() error {
|
|
return h.auth.Close()
|
|
}
|
|
|
|
type userStatusResponse struct {
|
|
HasDeviceExtensions bool `json:"hasDeviceExtensions,omitempty"`
|
|
Message string `json:"message"` // Always set to "ok"
|
|
}
|
|
|
|
func (h *Handler) getUserStatus(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) {
|
|
return userStatusResponse{
|
|
HasDeviceExtensions: c.cfg.Session.GetHasDeviceExtensions(),
|
|
Message: "ok",
|
|
}, nil
|
|
}
|
|
|
|
// handleGetUserOrResetToken has two handlers:
|
|
// - read user
|
|
// - return reset password token
|
|
// It has two because the expected route for reading a user overlaps with an already existing one
|
|
// Using `GET /webapi/users/:username` invalidates the `GET /webapi/users/password/token/:token` route
|
|
// An alternative would be using the resource's singular name `GET /webapi/user/:username` but it invalidates the `GET /webapi/user/status` route
|
|
// So, instead we'll use `GET /webapi/users/*wildcard`, parse the path/params and call the appropriate handler
|
|
func (h *Handler) handleGetUserOrResetToken(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
|
// do we have multiple path fields or just one
|
|
relativePath := p.ByName("wildcard")
|
|
relativePath = strings.TrimPrefix(relativePath, "/") // relativePath might start with "/", removing it helps reasoning
|
|
pathFields := strings.Split(relativePath, "/")
|
|
|
|
params := httprouter.Params{}
|
|
|
|
handleFunc := httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
http.NotFound(w, r)
|
|
return nil, nil
|
|
})
|
|
|
|
// having one means we have an username
|
|
if len(pathFields) == 1 {
|
|
params = httprouter.Params{httprouter.Param{
|
|
Key: "username",
|
|
Value: pathFields[0],
|
|
}}
|
|
|
|
handleFunc = h.WithAuth(h.getUserHandle)
|
|
}
|
|
|
|
// if we have exactly 3 and they look like /password/token/:token
|
|
if len(pathFields) == 3 && pathFields[0] == "password" && pathFields[1] == "token" && pathFields[2] != "" {
|
|
params = httprouter.Params{httprouter.Param{
|
|
Key: "token",
|
|
Value: pathFields[2],
|
|
}}
|
|
|
|
handleFunc = httplib.MakeHandler(h.getResetPasswordTokenHandle)
|
|
}
|
|
|
|
handleFunc(w, r, params)
|
|
}
|
|
|
|
// getUserContext returns user context
|
|
//
|
|
// GET /webapi/sites/:site/context
|
|
func (h *Handler) getUserContext(w http.ResponseWriter, r *http.Request, p httprouter.Params, c *SessionContext, site reversetunnelclient.RemoteSite) (any, error) {
|
|
cn, err := h.cfg.AccessPoint.GetClusterName()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
if cn.GetClusterName() != site.GetName() {
|
|
return nil, trace.BadParameter("endpoint only implemented for root cluster")
|
|
}
|
|
accessChecker, err := c.GetUserAccessChecker()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clt, err := c.GetClient()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
user, err := clt.GetUser(r.Context(), c.GetUser(), false)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// The following section is similar to
|
|
// https://github.com/gravitational/teleport/blob/ea810d30d99f26e58a190edc5facfbe0c09ea5e5/lib/srv/desktop/windows_server.go#L757-L769
|
|
recConfig, err := c.cfg.UnsafeCachedAuthClient.GetSessionRecordingConfig(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
desktopRecordingEnabled := recConfig.GetMode() != types.RecordOff
|
|
|
|
pingResp, err := clt.Ping(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
accessMonitoringEnabled := pingResp.GetServerFeatures().GetAccessMonitoring().GetEnabled()
|
|
|
|
// DELETE IN 16.0
|
|
// If ServerFeatures.AccessMonitoring is nil, then that means the response came from a older auth
|
|
// where ServerFeatures.AccessMonitoring field does not exist.
|
|
if pingResp.GetServerFeatures().GetAccessMonitoring() == nil {
|
|
accessMonitoringEnabled = pingResp.ServerFeatures != nil && pingResp.ServerFeatures.GetIdentityGovernance()
|
|
}
|
|
|
|
userContext, err := ui.NewUserContext(user, accessChecker.Roles(), *pingResp.ServerFeatures, desktopRecordingEnabled, accessMonitoringEnabled)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
res, err := clt.GetAccessCapabilities(r.Context(), types.AccessCapabilitiesRequest{
|
|
RequestableRoles: true,
|
|
SuggestedReviewers: true,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
userContext.AccessCapabilities = ui.AccessCapabilities{
|
|
RequestableRoles: res.RequestableRoles,
|
|
SuggestedReviewers: res.SuggestedReviewers,
|
|
}
|
|
|
|
userContext.AllowedSearchAsRoles = accessChecker.GetAllowedSearchAsRoles()
|
|
|
|
userContext.Cluster, err = ui.GetClusterDetails(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
userContext.ConsumedAccessRequestID = c.cfg.Session.GetConsumedAccessRequestID()
|
|
|
|
return userContext, nil
|
|
}
|
|
|
|
// PublicProxyAddr returns the publicly advertised proxy address
|
|
func (h *Handler) PublicProxyAddr() string {
|
|
return h.cfg.PublicProxyAddr
|
|
}
|
|
|
|
// AccessGraphAddr returns the TAG API address
|
|
func (h *Handler) AccessGraphAddr() utils.NetAddr {
|
|
return h.cfg.AccessGraphAddr
|
|
}
|
|
|
|
func localSettings(cap types.AuthPreference) (webclient.AuthenticationSettings, error) {
|
|
as := webclient.AuthenticationSettings{
|
|
Type: constants.Local,
|
|
SecondFactor: cap.GetSecondFactor(),
|
|
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
|
|
AllowPasswordless: cap.GetAllowPasswordless(),
|
|
AllowHeadless: cap.GetAllowHeadless(),
|
|
Local: &webclient.LocalSettings{},
|
|
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
|
|
PIVSlot: cap.GetPIVSlot(),
|
|
DeviceTrust: deviceTrustSettings(cap),
|
|
}
|
|
|
|
// Only copy the connector name if it's truly local and not a local fallback.
|
|
if cap.GetType() == constants.Local {
|
|
as.Local.Name = cap.GetConnectorName()
|
|
}
|
|
|
|
// U2F settings.
|
|
switch u2f, err := cap.GetU2F(); {
|
|
case err == nil:
|
|
as.U2F = &webclient.U2FSettings{AppID: u2f.AppID}
|
|
case !trace.IsNotFound(err):
|
|
log.WithError(err).Warnf("Error reading U2F settings")
|
|
}
|
|
|
|
// Webauthn settings.
|
|
switch webConfig, err := cap.GetWebauthn(); {
|
|
case err == nil:
|
|
as.Webauthn = &webclient.Webauthn{
|
|
RPID: webConfig.RPID,
|
|
}
|
|
case !trace.IsNotFound(err):
|
|
log.WithError(err).Warnf("Error reading WebAuthn settings")
|
|
}
|
|
|
|
return as, nil
|
|
}
|
|
|
|
func oidcSettings(connector types.OIDCConnector, cap types.AuthPreference) webclient.AuthenticationSettings {
|
|
return webclient.AuthenticationSettings{
|
|
Type: constants.OIDC,
|
|
OIDC: &webclient.OIDCSettings{
|
|
Name: connector.GetName(),
|
|
Display: connector.GetDisplay(),
|
|
},
|
|
// Local fallback / MFA.
|
|
SecondFactor: cap.GetSecondFactor(),
|
|
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
|
|
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
|
|
PIVSlot: cap.GetPIVSlot(),
|
|
DeviceTrust: deviceTrustSettings(cap),
|
|
}
|
|
}
|
|
|
|
func samlSettings(connector types.SAMLConnector, cap types.AuthPreference) webclient.AuthenticationSettings {
|
|
return webclient.AuthenticationSettings{
|
|
Type: constants.SAML,
|
|
SAML: &webclient.SAMLSettings{
|
|
Name: connector.GetName(),
|
|
Display: connector.GetDisplay(),
|
|
},
|
|
// Local fallback / MFA.
|
|
SecondFactor: cap.GetSecondFactor(),
|
|
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
|
|
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
|
|
PIVSlot: cap.GetPIVSlot(),
|
|
DeviceTrust: deviceTrustSettings(cap),
|
|
}
|
|
}
|
|
|
|
func githubSettings(connector types.GithubConnector, cap types.AuthPreference) webclient.AuthenticationSettings {
|
|
return webclient.AuthenticationSettings{
|
|
Type: constants.Github,
|
|
Github: &webclient.GithubSettings{
|
|
Name: connector.GetName(),
|
|
Display: connector.GetDisplay(),
|
|
},
|
|
// Local fallback / MFA.
|
|
SecondFactor: cap.GetSecondFactor(),
|
|
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
|
|
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
|
|
PIVSlot: cap.GetPIVSlot(),
|
|
DeviceTrust: deviceTrustSettings(cap),
|
|
}
|
|
}
|
|
|
|
func deviceTrustSettings(cap types.AuthPreference) webclient.DeviceTrustSettings {
|
|
dt := cap.GetDeviceTrust()
|
|
return webclient.DeviceTrustSettings{
|
|
Disabled: deviceTrustDisabled(cap),
|
|
AutoEnroll: dt != nil && dt.AutoEnroll,
|
|
}
|
|
}
|
|
|
|
// deviceTrustDisabled is used to set its namesake field in
|
|
// [webclient.PingResponse.Auth].
|
|
func deviceTrustDisabled(cap types.AuthPreference) bool {
|
|
return dtconfig.GetEffectiveMode(cap.GetDeviceTrust()) == constants.DeviceTrustModeOff
|
|
}
|
|
|
|
func getAuthSettings(ctx context.Context, authClient authclient.ClientI) (webclient.AuthenticationSettings, error) {
|
|
authPreference, err := authClient.GetAuthPreference(ctx)
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
|
|
var as webclient.AuthenticationSettings
|
|
|
|
switch authPreference.GetType() {
|
|
case constants.Local:
|
|
as, err = localSettings(authPreference)
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
case constants.OIDC:
|
|
if authPreference.GetConnectorName() != "" {
|
|
oidcConnector, err := authClient.GetOIDCConnector(ctx, authPreference.GetConnectorName(), false)
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
|
|
as = oidcSettings(oidcConnector, authPreference)
|
|
} else {
|
|
oidcConnectors, err := authClient.GetOIDCConnectors(ctx, false)
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
if len(oidcConnectors) == 0 {
|
|
return webclient.AuthenticationSettings{}, trace.BadParameter("no oidc connectors found")
|
|
}
|
|
|
|
as = oidcSettings(oidcConnectors[0], authPreference)
|
|
}
|
|
case constants.SAML:
|
|
if authPreference.GetConnectorName() != "" {
|
|
samlConnector, err := authClient.GetSAMLConnector(ctx, authPreference.GetConnectorName(), false)
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
|
|
as = samlSettings(samlConnector, authPreference)
|
|
} else {
|
|
samlConnectors, err := authClient.GetSAMLConnectors(ctx, false)
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
if len(samlConnectors) == 0 {
|
|
return webclient.AuthenticationSettings{}, trace.BadParameter("no saml connectors found")
|
|
}
|
|
|
|
as = samlSettings(samlConnectors[0], authPreference)
|
|
}
|
|
case constants.Github:
|
|
if authPreference.GetConnectorName() != "" {
|
|
githubConnector, err := authClient.GetGithubConnector(ctx, authPreference.GetConnectorName(), false)
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
as = githubSettings(githubConnector, authPreference)
|
|
} else {
|
|
githubConnectors, err := authClient.GetGithubConnectors(ctx, false)
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
if len(githubConnectors) == 0 {
|
|
return webclient.AuthenticationSettings{}, trace.BadParameter("no github connectors found")
|
|
}
|
|
as = githubSettings(githubConnectors[0], authPreference)
|
|
}
|
|
default:
|
|
return webclient.AuthenticationSettings{}, trace.BadParameter("unknown type %v", authPreference.GetType())
|
|
}
|
|
|
|
as.HasMessageOfTheDay = authPreference.GetMessageOfTheDay() != ""
|
|
pingResp, err := authClient.Ping(ctx)
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
as.LoadAllCAs = pingResp.LoadAllCAs
|
|
as.DefaultSessionTTL = authPreference.GetDefaultSessionTTL()
|
|
|
|
return as, nil
|
|
}
|
|
|
|
// traces forwards spans from the web ui to the upstream collector configured for the proxy. If tracing is
|
|
// disabled then the forwarding is a noop.
|
|
func (h *Handler) traces(w http.ResponseWriter, r *http.Request, _ httprouter.Params, _ *SessionContext) (interface{}, error) {
|
|
body, err := utils.ReadAtMost(r.Body, teleport.MaxHTTPResponseSize)
|
|
if err != nil {
|
|
h.log.WithError(err).Error("Failed to read traces request")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return nil, nil
|
|
}
|
|
|
|
if err := r.Body.Close(); err != nil {
|
|
h.log.WithError(err).Warn("Failed to close traces request body")
|
|
}
|
|
|
|
var data tracepb.TracesData
|
|
if err := protojson.Unmarshal(body, &data); err != nil {
|
|
h.log.WithError(err).Error("Failed to unmarshal traces request")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return nil, nil
|
|
}
|
|
|
|
if len(data.ResourceSpans) == 0 {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return nil, nil
|
|
}
|
|
|
|
// Unmarshalling of TraceId, SpanId, and ParentSpanId might all yield incorrect values. The raw values from
|
|
// OpenTelemetry-js are hex encoded, but the unmarshal call above will decode them as base64.
|
|
// In order to ensure the ids are in the right format and won't be rejected by the upstream collector
|
|
// we attempt to convert them back into the base64 and then hex decode them.
|
|
for _, resourceSpan := range data.ResourceSpans {
|
|
for _, scopeSpan := range resourceSpan.ScopeSpans {
|
|
for _, span := range scopeSpan.Spans {
|
|
|
|
// attempt to convert the trace id to the right format
|
|
if tid, err := oteltrace.TraceIDFromHex(base64.StdEncoding.EncodeToString(span.TraceId)); err == nil {
|
|
span.TraceId = tid[:]
|
|
}
|
|
|
|
// attempt to convert the span id to the right format
|
|
if sid, err := oteltrace.SpanIDFromHex(base64.StdEncoding.EncodeToString(span.SpanId)); err == nil {
|
|
span.SpanId = sid[:]
|
|
}
|
|
|
|
// attempt to convert the parent span id to the right format
|
|
if len(span.ParentSpanId) > 0 {
|
|
if psid, err := oteltrace.SpanIDFromHex(base64.StdEncoding.EncodeToString(span.ParentSpanId)); err == nil {
|
|
span.ParentSpanId = psid[:]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
go func() {
|
|
// Because the uploading happens in a goroutine we cannot use the request scoped context
|
|
// since it will more than likely get canceled prior to the traces being uploaded. Use
|
|
// a background context with a lenient timeout to allow for a large number of spans to complete.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := h.cfg.TraceClient.UploadTraces(ctx, data.ResourceSpans); err != nil {
|
|
h.log.WithError(err).Error("Failed to upload traces")
|
|
}
|
|
}()
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
return nil, nil
|
|
}
|
|
|
|
func (h *Handler) ping(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
var err error
|
|
authSettings, err := getAuthSettings(r.Context(), h.cfg.ProxyClient)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
proxyConfig, err := h.cfg.ProxySettings.GetProxySettings(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// TODO(jakule): This part should be removed once the plugin support is added to OSS.
|
|
if proxyConfig.AssistEnabled {
|
|
enabled, err := h.cfg.ProxyClient.IsAssistEnabled(r.Context())
|
|
if err != nil {
|
|
return webclient.AuthenticationSettings{}, trace.Wrap(err)
|
|
}
|
|
|
|
// disable if auth doesn't support assist
|
|
proxyConfig.AssistEnabled = enabled.Enabled
|
|
}
|
|
|
|
pr, err := h.cfg.ProxyClient.Ping(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return webclient.PingResponse{
|
|
Auth: authSettings,
|
|
Proxy: *proxyConfig,
|
|
ServerVersion: teleport.Version,
|
|
MinClientVersion: teleport.MinClientVersion,
|
|
ClusterName: h.auth.clusterName,
|
|
AutomaticUpgrades: pr.ServerFeatures.GetAutomaticUpgrades(),
|
|
}, nil
|
|
}
|
|
|
|
func (h *Handler) find(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
// TODO(jent,espadolini): add a time-based cache to further reduce load on this endpoint
|
|
proxyConfig, err := h.cfg.ProxySettings.GetProxySettings(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return webclient.PingResponse{
|
|
Proxy: *proxyConfig,
|
|
ServerVersion: teleport.Version,
|
|
MinClientVersion: teleport.MinClientVersion,
|
|
ClusterName: h.auth.clusterName,
|
|
}, nil
|
|
}
|
|
|
|
func (h *Handler) pingWithConnector(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
authClient := h.cfg.ProxyClient
|
|
connectorName := p.ByName("connector")
|
|
|
|
cap, err := authClient.GetAuthPreference(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
pingResp, err := authClient.Ping(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
loadAllCAs := pingResp.LoadAllCAs
|
|
|
|
proxyConfig, err := h.cfg.ProxySettings.GetProxySettings(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
response := &webclient.PingResponse{
|
|
Proxy: *proxyConfig,
|
|
ServerVersion: teleport.Version,
|
|
ClusterName: h.auth.clusterName,
|
|
}
|
|
|
|
hasMessageOfTheDay := cap.GetMessageOfTheDay() != ""
|
|
if slices.Contains(constants.SystemConnectors, connectorName) {
|
|
response.Auth, err = localSettings(cap)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
response.Auth.HasMessageOfTheDay = hasMessageOfTheDay
|
|
response.Auth.LoadAllCAs = loadAllCAs
|
|
response.Auth.Local.Name = connectorName // echo connector queried by caller
|
|
return response, nil
|
|
}
|
|
|
|
// collectorNames stores a list of the registered collector names so that
|
|
// in the event that no connector has matched, the list can be returned.
|
|
var collectorNames []string
|
|
|
|
// first look for a oidc connector with that name
|
|
oidcConnectors, err := authClient.GetOIDCConnectors(r.Context(), false)
|
|
if err == nil {
|
|
for index, value := range oidcConnectors {
|
|
collectorNames = append(collectorNames, value.GetMetadata().Name)
|
|
if value.GetMetadata().Name == connectorName {
|
|
response.Auth = oidcSettings(oidcConnectors[index], cap)
|
|
response.Auth.HasMessageOfTheDay = hasMessageOfTheDay
|
|
response.Auth.LoadAllCAs = loadAllCAs
|
|
return response, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// if no oidc connector was found, look for a saml connector
|
|
samlConnectors, err := authClient.GetSAMLConnectors(r.Context(), false)
|
|
if err == nil {
|
|
for index, value := range samlConnectors {
|
|
collectorNames = append(collectorNames, value.GetMetadata().Name)
|
|
if value.GetMetadata().Name == connectorName {
|
|
response.Auth = samlSettings(samlConnectors[index], cap)
|
|
response.Auth.HasMessageOfTheDay = hasMessageOfTheDay
|
|
response.Auth.LoadAllCAs = loadAllCAs
|
|
return response, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// look for github connector
|
|
githubConnectors, err := authClient.GetGithubConnectors(r.Context(), false)
|
|
if err == nil {
|
|
for index, value := range githubConnectors {
|
|
collectorNames = append(collectorNames, value.GetMetadata().Name)
|
|
if value.GetMetadata().Name == connectorName {
|
|
response.Auth = githubSettings(githubConnectors[index], cap)
|
|
response.Auth.HasMessageOfTheDay = hasMessageOfTheDay
|
|
response.Auth.LoadAllCAs = loadAllCAs
|
|
return response, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil,
|
|
trace.BadParameter(
|
|
"invalid connector name: %v; valid options: %s",
|
|
connectorName, strings.Join(collectorNames, ", "))
|
|
}
|
|
|
|
// getWebConfig returns configuration for the web application.
|
|
func (h *Handler) getWebConfig(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
httplib.SetWebConfigHeaders(w.Header())
|
|
|
|
authProviders := []webclient.WebConfigAuthProvider{}
|
|
|
|
// get all OIDC connectors
|
|
oidcConnectors, err := h.cfg.ProxyClient.GetOIDCConnectors(r.Context(), false)
|
|
if err != nil {
|
|
h.log.WithError(err).Error("Cannot retrieve OIDC connectors.")
|
|
}
|
|
for _, item := range oidcConnectors {
|
|
authProviders = append(authProviders, webclient.WebConfigAuthProvider{
|
|
Type: webclient.WebConfigAuthProviderOIDCType,
|
|
WebAPIURL: webclient.WebConfigAuthProviderOIDCURL,
|
|
Name: item.GetName(),
|
|
DisplayName: item.GetDisplay(),
|
|
})
|
|
}
|
|
|
|
// get all SAML connectors
|
|
samlConnectors, err := h.cfg.ProxyClient.GetSAMLConnectors(r.Context(), false)
|
|
if err != nil {
|
|
h.log.WithError(err).Error("Cannot retrieve SAML connectors.")
|
|
}
|
|
for _, item := range samlConnectors {
|
|
authProviders = append(authProviders, webclient.WebConfigAuthProvider{
|
|
Type: webclient.WebConfigAuthProviderSAMLType,
|
|
WebAPIURL: webclient.WebConfigAuthProviderSAMLURL,
|
|
Name: item.GetName(),
|
|
DisplayName: item.GetDisplay(),
|
|
})
|
|
}
|
|
|
|
// get all Github connectors
|
|
githubConnectors, err := h.cfg.ProxyClient.GetGithubConnectors(r.Context(), false)
|
|
if err != nil {
|
|
h.log.WithError(err).Error("Cannot retrieve GitHub connectors.")
|
|
}
|
|
for _, item := range githubConnectors {
|
|
authProviders = append(authProviders, webclient.WebConfigAuthProvider{
|
|
Type: webclient.WebConfigAuthProviderGitHubType,
|
|
WebAPIURL: webclient.WebConfigAuthProviderGitHubURL,
|
|
Name: item.GetName(),
|
|
DisplayName: item.GetDisplay(),
|
|
})
|
|
}
|
|
|
|
// get auth type & second factor type
|
|
var authSettings webclient.WebConfigAuthSettings
|
|
if cap, err := h.cfg.ProxyClient.GetAuthPreference(r.Context()); err != nil {
|
|
h.log.WithError(err).Error("Cannot retrieve AuthPreferences.")
|
|
authSettings = webclient.WebConfigAuthSettings{
|
|
Providers: authProviders,
|
|
SecondFactor: constants.SecondFactorOff,
|
|
LocalAuthEnabled: true,
|
|
AuthType: constants.Local,
|
|
}
|
|
} else {
|
|
authType := cap.GetType()
|
|
var localConnectorName string
|
|
|
|
if authType == constants.Local {
|
|
localConnectorName = cap.GetConnectorName()
|
|
}
|
|
|
|
authSettings = webclient.WebConfigAuthSettings{
|
|
Providers: authProviders,
|
|
SecondFactor: cap.GetSecondFactor(),
|
|
LocalAuthEnabled: cap.GetAllowLocalAuth(),
|
|
AllowPasswordless: cap.GetAllowPasswordless(),
|
|
AuthType: authType,
|
|
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
|
|
LocalConnectorName: localConnectorName,
|
|
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
|
|
MOTD: cap.GetMessageOfTheDay(),
|
|
}
|
|
}
|
|
|
|
clusterFeatures := h.ClusterFeatures
|
|
// ping server to get cluster features since h.ClusterFeatures may be stale
|
|
pingResponse, err := h.GetProxyClient().Ping(r.Context())
|
|
if err != nil {
|
|
h.log.WithError(err).Warn("Cannot retrieve cluster features, client may receive stale features")
|
|
} else {
|
|
clusterFeatures = *pingResponse.ServerFeatures
|
|
}
|
|
|
|
// get tunnel address to display on cloud instances
|
|
tunnelPublicAddr := ""
|
|
assistEnabled := false // TODO(jakule) remove when plugins are implemented
|
|
proxyConfig, err := h.cfg.ProxySettings.GetProxySettings(r.Context())
|
|
if err != nil {
|
|
h.log.WithError(err).Warn("Cannot retrieve ProxySettings, tunnel address won't be set in Web UI.")
|
|
} else {
|
|
if clusterFeatures.GetCloud() {
|
|
tunnelPublicAddr = proxyConfig.SSH.TunnelPublicAddr
|
|
}
|
|
// TODO(jakule): This part should be removed once the plugin support is added to OSS.
|
|
if proxyConfig.AssistEnabled {
|
|
enabled, err := h.cfg.ProxyClient.IsAssistEnabled(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// disable if auth doesn't support assist
|
|
assistEnabled = enabled.Enabled
|
|
}
|
|
}
|
|
|
|
// disable joining sessions if proxy session recording is enabled
|
|
canJoinSessions := true
|
|
recCfg, err := h.cfg.ProxyClient.GetSessionRecordingConfig(r.Context())
|
|
if err != nil {
|
|
h.log.WithError(err).Error("Cannot retrieve SessionRecordingConfig.")
|
|
} else {
|
|
canJoinSessions = !services.IsRecordAtProxy(recCfg.GetMode())
|
|
}
|
|
|
|
automaticUpgradesEnabled := clusterFeatures.GetAutomaticUpgrades()
|
|
var automaticUpgradesTargetVersion string
|
|
if automaticUpgradesEnabled {
|
|
automaticUpgradesTargetVersion, err = h.cfg.AutomaticUpgradesChannels.DefaultVersion(r.Context())
|
|
h.log.WithError(err).Error("Cannot read target version")
|
|
}
|
|
|
|
// TODO(mcbattirola): remove isTeam when it is no longer used
|
|
isTeam := clusterFeatures.GetProductType() == proto.ProductType_PRODUCT_TYPE_TEAM
|
|
|
|
webCfg := webclient.WebConfig{
|
|
Edition: modules.GetModules().BuildType(),
|
|
Auth: authSettings,
|
|
CanJoinSessions: canJoinSessions,
|
|
IsCloud: clusterFeatures.GetCloud(),
|
|
TunnelPublicAddress: tunnelPublicAddr,
|
|
RecoveryCodesEnabled: clusterFeatures.GetRecoveryCodes(),
|
|
UI: h.getUIConfig(r.Context()),
|
|
IsDashboard: services.IsDashboard(clusterFeatures),
|
|
IsUsageBasedBilling: clusterFeatures.GetIsUsageBased(),
|
|
AutomaticUpgrades: automaticUpgradesEnabled,
|
|
AutomaticUpgradesTargetVersion: automaticUpgradesTargetVersion,
|
|
AssistEnabled: assistEnabled,
|
|
HideInaccessibleFeatures: clusterFeatures.GetFeatureHiding(),
|
|
CustomTheme: clusterFeatures.GetCustomTheme(),
|
|
IsIGSEnabled: clusterFeatures.GetIdentityGovernance(),
|
|
FeatureLimits: webclient.FeatureLimits{
|
|
AccessListCreateLimit: int(clusterFeatures.GetAccessList().GetCreateLimit()),
|
|
AccessMonitoringMaxReportRangeLimit: int(clusterFeatures.GetAccessMonitoring().GetMaxReportRangeLimit()),
|
|
AccessRequestMonthlyRequestLimit: int(clusterFeatures.GetAccessRequests().GetMonthlyRequestLimit()),
|
|
},
|
|
Questionnaire: clusterFeatures.GetQuestionnaire(),
|
|
IsStripeManaged: clusterFeatures.GetIsStripeManaged(),
|
|
ExternalAuditStorage: clusterFeatures.GetExternalAuditStorage(),
|
|
PremiumSupport: clusterFeatures.GetSupportType() == proto.SupportType_SUPPORT_TYPE_PREMIUM,
|
|
AccessRequests: clusterFeatures.GetAccessRequests().MonthlyRequestLimit > 0,
|
|
TrustedDevices: clusterFeatures.GetDeviceTrust().GetEnabled(),
|
|
OIDC: clusterFeatures.GetOIDC(),
|
|
SAML: clusterFeatures.GetSAML(),
|
|
MobileDeviceManagement: clusterFeatures.GetMobileDeviceManagement(),
|
|
JoinActiveSessions: clusterFeatures.GetJoinActiveSessions(),
|
|
// TODO(mcbattirola): remove isTeam when it is no longer used
|
|
IsTeam: isTeam,
|
|
}
|
|
|
|
resource, err := h.cfg.ProxyClient.GetClusterName()
|
|
if err != nil {
|
|
h.log.WithError(err).Warn("Failed to query cluster name.")
|
|
} else {
|
|
webCfg.ProxyClusterName = resource.GetClusterName()
|
|
}
|
|
|
|
out, err := json.Marshal(webCfg)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
fmt.Fprintf(w, "var GRV_CONFIG = %v;", string(out))
|
|
return nil, nil
|
|
}
|
|
|
|
type JWKSResponse struct {
|
|
// Keys is a list of public keys in JWK format.
|
|
Keys []jwt.JWK `json:"keys"`
|
|
}
|
|
|
|
// getUiConfig will first try to get an ui_config set in the cache and then
|
|
// return what was set by the file config. Returns nil if neither are set which
|
|
// is fine, as the web UI can set its own defaults.
|
|
func (h *Handler) getUIConfig(ctx context.Context) webclient.UIConfig {
|
|
if uiConfig, err := h.cfg.AccessPoint.GetUIConfig(ctx); err == nil && uiConfig != nil {
|
|
return webclient.UIConfig{
|
|
ScrollbackLines: int(uiConfig.GetScrollbackLines()),
|
|
ShowResources: uiConfig.GetShowResources(),
|
|
}
|
|
}
|
|
return h.cfg.UI
|
|
}
|
|
|
|
// jwks returns all public keys used to sign JWT tokens for this cluster.
|
|
func (h *Handler) wellKnownJWKS(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
return h.jwks(r.Context(), types.JWTSigner)
|
|
}
|
|
|
|
func (h *Handler) motd(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
authPrefs, err := h.cfg.ProxyClient.GetAuthPreference(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return webclient.MotD{Text: authPrefs.GetMessageOfTheDay()}, nil
|
|
}
|
|
|
|
func (h *Handler) githubLoginWeb(w http.ResponseWriter, r *http.Request, p httprouter.Params) string {
|
|
logger := h.log.WithField("auth", "github")
|
|
logger.Debug("Web login start.")
|
|
|
|
req, err := ParseSSORequestParams(r)
|
|
if err != nil {
|
|
logger.WithError(err).Error("Failed to extract SSO parameters from request.")
|
|
return client.LoginFailedRedirectURL
|
|
}
|
|
|
|
remoteAddr, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
logger.WithError(err).Error("Failed to parse request remote address.")
|
|
return client.LoginFailedRedirectURL
|
|
}
|
|
|
|
response, err := h.cfg.ProxyClient.CreateGithubAuthRequest(r.Context(), types.GithubAuthRequest{
|
|
CSRFToken: req.CSRFToken,
|
|
ConnectorID: req.ConnectorID,
|
|
CreateWebSession: true,
|
|
ClientRedirectURL: req.ClientRedirectURL,
|
|
ClientLoginIP: remoteAddr,
|
|
})
|
|
if err != nil {
|
|
logger.WithError(err).Error("Error creating auth request.")
|
|
return client.LoginFailedRedirectURL
|
|
|
|
}
|
|
|
|
return response.RedirectURL
|
|
}
|
|
|
|
func (h *Handler) githubLoginConsole(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
logger := h.log.WithField("auth", "github")
|
|
logger.Debug("Console login start.")
|
|
|
|
req := new(client.SSOLoginConsoleReq)
|
|
if err := httplib.ReadJSON(r, req); err != nil {
|
|
logger.WithError(err).Error("Error reading json.")
|
|
return nil, trace.AccessDenied(SSOLoginFailureMessage)
|
|
}
|
|
|
|
if err := req.CheckAndSetDefaults(); err != nil {
|
|
logger.WithError(err).Error("Missing request parameters.")
|
|
return nil, trace.AccessDenied(SSOLoginFailureMessage)
|
|
}
|
|
|
|
remoteAddr, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
logger.WithError(err).Error("Failed to parse request remote address.")
|
|
return nil, trace.AccessDenied(SSOLoginFailureMessage)
|
|
}
|
|
|
|
response, err := h.cfg.ProxyClient.CreateGithubAuthRequest(r.Context(), types.GithubAuthRequest{
|
|
ConnectorID: req.ConnectorID,
|
|
PublicKey: req.PublicKey,
|
|
CertTTL: req.CertTTL,
|
|
ClientRedirectURL: req.RedirectURL,
|
|
Compatibility: req.Compatibility,
|
|
RouteToCluster: req.RouteToCluster,
|
|
KubernetesCluster: req.KubernetesCluster,
|
|
AttestationStatement: req.AttestationStatement.ToProto(),
|
|
ClientLoginIP: remoteAddr,
|
|
})
|
|
if err != nil {
|
|
logger.WithError(err).Error("Failed to create GitHub auth request.")
|
|
if strings.Contains(err.Error(), auth.InvalidClientRedirectErrorMessage) {
|
|
return nil, trace.AccessDenied(SSOLoginFailureInvalidRedirect)
|
|
}
|
|
return nil, trace.AccessDenied(SSOLoginFailureMessage)
|
|
}
|
|
|
|
return &client.SSOLoginConsoleResponse{
|
|
RedirectURL: response.RedirectURL,
|
|
}, nil
|
|
}
|
|
|
|
func (h *Handler) githubCallback(w http.ResponseWriter, r *http.Request, p httprouter.Params) string {
|
|
logger := h.log.WithField("auth", "github")
|
|
logger.Debugf("Callback start: %v.", r.URL.Query())
|
|
|
|
response, err := h.cfg.ProxyClient.ValidateGithubAuthCallback(r.Context(), r.URL.Query())
|
|
if err != nil {
|
|
logger.WithError(err).Error("Error while processing callback.")
|
|
|
|
// try to find the auth request, which bears the original client redirect URL.
|
|
// if found, use it to terminate the flow.
|
|
//
|
|
// this improves the UX by terminating the failed SSO flow immediately, rather than hoping for a timeout.
|
|
if requestID := r.URL.Query().Get("state"); requestID != "" {
|
|
if request, errGet := h.cfg.ProxyClient.GetGithubAuthRequest(r.Context(), requestID); errGet == nil && !request.CreateWebSession {
|
|
if redURL, errEnc := RedirectURLWithError(request.ClientRedirectURL, err); errEnc == nil {
|
|
return redURL.String()
|
|
}
|
|
}
|
|
}
|
|
if errors.Is(err, auth.ErrGithubNoTeams) {
|
|
return client.LoginFailedUnauthorizedRedirectURL
|
|
}
|
|
|
|
return client.LoginFailedBadCallbackRedirectURL
|
|
}
|
|
|
|
// if we created web session, set session cookie and redirect to original url
|
|
if response.Req.CreateWebSession {
|
|
logger.Infof("Redirecting to web browser.")
|
|
|
|
res := &SSOCallbackResponse{
|
|
CSRFToken: response.Req.CSRFToken,
|
|
Username: response.Username,
|
|
SessionName: response.Session.GetName(),
|
|
ClientRedirectURL: response.Req.ClientRedirectURL,
|
|
}
|
|
|
|
if err := SSOSetWebSessionAndRedirectURL(w, r, res, true); err != nil {
|
|
logger.WithError(err).Error("Error setting web session.")
|
|
return client.LoginFailedRedirectURL
|
|
}
|
|
|
|
return res.ClientRedirectURL
|
|
}
|
|
|
|
logger.Infof("Callback is redirecting to console login.")
|
|
if len(response.Req.PublicKey) == 0 {
|
|
logger.Error("Not a web or console login request.")
|
|
return client.LoginFailedRedirectURL
|
|
}
|
|
|
|
redirectURL, err := ConstructSSHResponse(AuthParams{
|
|
ClientRedirectURL: response.Req.ClientRedirectURL,
|
|
Username: response.Username,
|
|
Identity: response.Identity,
|
|
Session: response.Session,
|
|
Cert: response.Cert,
|
|
TLSCert: response.TLSCert,
|
|
HostSigners: response.HostSigners,
|
|
FIPS: h.cfg.FIPS,
|
|
})
|
|
if err != nil {
|
|
logger.WithError(err).Error("Error constructing ssh response")
|
|
return client.LoginFailedRedirectURL
|
|
}
|
|
|
|
return redirectURL.String()
|
|
}
|
|
|
|
func (h *Handler) installer(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
httplib.SetScriptHeaders(w.Header())
|
|
|
|
installerName := p.ByName("name")
|
|
installer, err := h.auth.proxyClient.GetInstaller(r.Context(), installerName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
ping, err := h.auth.Ping(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
// semver parsing requires a 'v' at the beginning of the version string.
|
|
version := semver.Major("v" + ping.ServerVersion)
|
|
instTmpl, err := template.New("").Parse(installer.GetScript())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
feats := modules.GetModules().Features()
|
|
teleportPackage := teleport.ComponentTeleport
|
|
if modules.GetModules().BuildType() == modules.BuildEnterprise || feats.Cloud {
|
|
teleportPackage = fmt.Sprintf("%s-%s", teleport.ComponentTeleport, modules.BuildEnterprise)
|
|
}
|
|
|
|
// By default, it uses the stable/v<majorVersion> channel.
|
|
repoChannel := fmt.Sprintf("stable/%s", version)
|
|
|
|
// If the updater must be installed, then change the repo to stable/cloud
|
|
// It must also install the version specified in
|
|
// https://updates.releases.teleport.dev/v1/stable/cloud/version
|
|
installUpdater := automaticUpgrades(*ping.ServerFeatures)
|
|
if installUpdater {
|
|
repoChannel = stableCloudChannelRepo
|
|
}
|
|
azureClientID := r.URL.Query().Get("azure-client-id")
|
|
|
|
tmpl := installers.Template{
|
|
PublicProxyAddr: h.PublicProxyAddr(),
|
|
MajorVersion: shsprintf.EscapeDefaultContext(version),
|
|
TeleportPackage: teleportPackage,
|
|
RepoChannel: shsprintf.EscapeDefaultContext(repoChannel),
|
|
AutomaticUpgrades: strconv.FormatBool(installUpdater),
|
|
AzureClientID: shsprintf.EscapeDefaultContext(azureClientID),
|
|
}
|
|
err = instTmpl.Execute(w, tmpl)
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// AuthParams are used to construct redirect URL containing auth
|
|
// information back to tsh login
|
|
type AuthParams struct {
|
|
// Username is authenticated teleport username
|
|
Username string
|
|
// Identity contains validated OIDC identity
|
|
Identity types.ExternalIdentity
|
|
// Web session will be generated by auth server if requested in OIDCAuthRequest
|
|
Session types.WebSession
|
|
// Cert will be generated by certificate authority
|
|
Cert []byte
|
|
// TLSCert is PEM encoded TLS certificate
|
|
TLSCert []byte
|
|
// HostSigners is a list of signing host public keys
|
|
// trusted by proxy, used in console login
|
|
HostSigners []types.CertAuthority
|
|
// ClientRedirectURL is a URL to redirect client to
|
|
ClientRedirectURL string
|
|
// FIPS mode means Teleport started in a FedRAMP/FIPS 140-2 compliant
|
|
// configuration.
|
|
FIPS bool
|
|
}
|
|
|
|
// ConstructSSHResponse creates a special SSH response for SSH login method
|
|
// that encodes everything using the client's secret key
|
|
func ConstructSSHResponse(response AuthParams) (*url.URL, error) {
|
|
u, err := url.Parse(response.ClientRedirectURL)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
consoleResponse := authclient.SSHLoginResponse{
|
|
Username: response.Username,
|
|
Cert: response.Cert,
|
|
TLSCert: response.TLSCert,
|
|
HostSigners: authclient.AuthoritiesToTrustedCerts(response.HostSigners),
|
|
}
|
|
out, err := json.Marshal(consoleResponse)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// Extract secret out of the request.
|
|
secretKey := u.Query().Get("secret_key")
|
|
if secretKey == "" {
|
|
return nil, trace.BadParameter("missing secret_key")
|
|
}
|
|
|
|
var ciphertext []byte
|
|
|
|
// AES-GCM based symmetric cipher.
|
|
key, err := secret.ParseKey([]byte(secretKey))
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
ciphertext, err = key.Seal(out)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// Place ciphertext into the redirect URL.
|
|
u.RawQuery = url.Values{"response": {string(ciphertext)}}.Encode()
|
|
return u, nil
|
|
}
|
|
|
|
// RedirectURLWithError adds an err query parameter to the given redirect URL with the
|
|
// given errReply message and returns the new URL. If the given URL cannot be parsed,
|
|
// an error is returned with a nil URL. It is used to return an error back to the
|
|
// original URL in an SSO callback when validation fails.
|
|
func RedirectURLWithError(clientRedirectURL string, errReply error) (*url.URL, error) {
|
|
u, err := url.Parse(clientRedirectURL)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
values := u.Query()
|
|
values.Set("err", errReply.Error())
|
|
|
|
u.RawQuery = values.Encode()
|
|
return u, nil
|
|
}
|
|
|
|
// CreateSessionReq is a request to create session from username, password and
|
|
// second factor token.
|
|
type CreateSessionReq struct {
|
|
// User is the Teleport username.
|
|
User string `json:"user"`
|
|
// Pass is the password.
|
|
Pass string `json:"pass"`
|
|
// SecondFactorToken is the OTP.
|
|
SecondFactorToken string `json:"second_factor_token"`
|
|
}
|
|
|
|
// String returns text description of this response
|
|
func (r *CreateSessionResponse) String() string {
|
|
return fmt.Sprintf("WebSession(type=%v,token=%v,expires=%vs)",
|
|
r.TokenType, r.Token, r.TokenExpiresIn)
|
|
}
|
|
|
|
// CreateSessionResponse returns OAuth compabible data about
|
|
// access token: https://tools.ietf.org/html/rfc6749
|
|
type CreateSessionResponse struct {
|
|
// TokenType is token type (bearer)
|
|
TokenType string `json:"type"`
|
|
// Token value
|
|
Token string `json:"token"`
|
|
// TokenExpiresIn sets seconds before this token is not valid
|
|
TokenExpiresIn int `json:"expires_in"`
|
|
// SessionExpires is when this session expires.
|
|
SessionExpires time.Time `json:"sessionExpires,omitempty"`
|
|
// SessionInactiveTimeoutMS specifies how long in milliseconds
|
|
// a user WebUI session can be left idle before being logged out
|
|
// by the server. A zero value means there is no idle timeout set.
|
|
SessionInactiveTimeoutMS int `json:"sessionInactiveTimeout"`
|
|
// DeviceWebToken is the token used to perform on-behalf-of device
|
|
// authentication.
|
|
// If not nil it should be forwarded to Connect for the device authentication
|
|
// ceremony.
|
|
DeviceWebToken *types.DeviceWebToken `json:"deviceWebToken,omitempty"`
|
|
}
|
|
|
|
func newSessionResponse(sctx *SessionContext) (*CreateSessionResponse, error) {
|
|
accessChecker, err := sctx.GetUserAccessChecker()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
_, err = accessChecker.CheckLoginDuration(0)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
token, err := sctx.getToken()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return &CreateSessionResponse{
|
|
TokenType: roundtrip.AuthBearer,
|
|
Token: token.GetName(),
|
|
TokenExpiresIn: int(token.Expiry().Sub(sctx.cfg.Parent.clock.Now()) / time.Second),
|
|
SessionInactiveTimeoutMS: int(sctx.cfg.Session.GetIdleTimeout().Milliseconds()),
|
|
DeviceWebToken: sctx.cfg.Session.GetDeviceWebToken(),
|
|
}, nil
|
|
}
|
|
|
|
// createWebSession creates a new web session based on user, pass and 2nd factor token
|
|
//
|
|
// POST /v1/webapi/sessions/web
|
|
//
|
|
// {"user": "alex", "pass": "abcdef123456", "second_factor_token": "token", "second_factor_type": "totp"}
|
|
//
|
|
// # Response
|
|
//
|
|
// {"type": "bearer", "token": "bearer token", "user": {"name": "alex", "allowed_logins": ["admin", "bob"]}, "expires_in": 20}
|
|
func (h *Handler) createWebSession(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
var req *CreateSessionReq
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// get cluster preferences to see if we should login
|
|
// with password or password+otp
|
|
authClient := h.cfg.ProxyClient
|
|
cap, err := authClient.GetAuthPreference(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clientMeta := clientMetaFromReq(r)
|
|
|
|
var webSession types.WebSession
|
|
switch cap.GetSecondFactor() {
|
|
case constants.SecondFactorOff:
|
|
webSession, err = h.auth.AuthWithoutOTP(r.Context(), req.User, req.Pass, clientMeta)
|
|
case constants.SecondFactorOTP, constants.SecondFactorOn:
|
|
webSession, err = h.auth.AuthWithOTP(r.Context(), req.User, req.Pass, req.SecondFactorToken, clientMeta)
|
|
case constants.SecondFactorOptional:
|
|
if req.SecondFactorToken == "" {
|
|
webSession, err = h.auth.AuthWithoutOTP(r.Context(), req.User, req.Pass, clientMeta)
|
|
} else {
|
|
webSession, err = h.auth.AuthWithOTP(r.Context(), req.User, req.Pass, req.SecondFactorToken, clientMeta)
|
|
}
|
|
default:
|
|
return nil, trace.AccessDenied("unknown second factor type: %q", cap.GetSecondFactor())
|
|
}
|
|
if err != nil {
|
|
h.log.WithError(err).Warnf("Access attempt denied for user %q.", req.User)
|
|
// Since checking for private key policy meant that they passed authn,
|
|
// return policy error as is to help direct user.
|
|
if keys.IsPrivateKeyPolicyError(err) {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
// Obscure all other errors.
|
|
return nil, trace.AccessDenied("invalid credentials")
|
|
}
|
|
|
|
if err := websession.SetCookie(w, req.User, webSession.GetName()); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
ctx, err := h.auth.newSessionContextFromSession(r.Context(), webSession)
|
|
if err != nil {
|
|
h.log.WithError(err).Warnf("Access attempt denied for user %q.", req.User)
|
|
return nil, trace.AccessDenied("need auth")
|
|
}
|
|
|
|
res, err := newSessionResponse(ctx)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
res.SessionExpires = webSession.GetExpiryTime()
|
|
|
|
return res, nil
|
|
}
|
|
|
|
func clientMetaFromReq(r *http.Request) *authclient.ForwardedClientMetadata {
|
|
return &authclient.ForwardedClientMetadata{
|
|
UserAgent: r.UserAgent(),
|
|
RemoteAddr: r.RemoteAddr,
|
|
}
|
|
}
|
|
|
|
// deleteWebSession is called to sign out user from web, app and SAML IdP session.
|
|
//
|
|
// DELETE /v1/webapi/sessions/:sid
|
|
//
|
|
// Response:
|
|
//
|
|
// {"message": "ok"}
|
|
func (h *Handler) deleteWebSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) {
|
|
// samlSessionCookie will not be set for users who are not authenticated with SAML IdP.
|
|
samlSessionCookie, err := r.Cookie(samlidp.SAMLSessionCookieName)
|
|
if err == nil && samlSessionCookie != nil && samlSessionCookie.Value != "" {
|
|
// TODO(sshah): Websession is not updated with SAML session details after SAML auth so
|
|
// it begs to check for a nil value below and then set a session with session ID
|
|
// retrieved from samlSessionCookie. We can skip this step below once we have a
|
|
// mechanism to update Websession with SAML session value.
|
|
if ctx.cfg.Session.GetSAMLSession() == nil {
|
|
ctx.cfg.Session.SetSAMLSession(&types.SAMLSessionData{ID: samlSessionCookie.Value})
|
|
}
|
|
}
|
|
|
|
err = h.logout(r.Context(), w, ctx)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return OK(), nil
|
|
}
|
|
|
|
func (h *Handler) logout(ctx context.Context, w http.ResponseWriter, sctx *SessionContext) error {
|
|
if err := sctx.Invalidate(ctx); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
clearSessionCookies(w)
|
|
|
|
return nil
|
|
}
|
|
|
|
// clearSessionCookies clears Web UI session and SAML session cookie.
|
|
func clearSessionCookies(w http.ResponseWriter) {
|
|
// Clear Web UI session cookie
|
|
websession.ClearCookie(w)
|
|
// Clear SAML IdP session cookie
|
|
samlidp.ClearCookie(w)
|
|
}
|
|
|
|
type renewSessionRequest struct {
|
|
// AccessRequestID is the id of an approved access request.
|
|
AccessRequestID string `json:"requestId"`
|
|
// Switchback indicates switching back to default roles when creating new session.
|
|
Switchback bool `json:"switchback"`
|
|
// ReloadUser is a flag to indicate if user needs to be refetched from the backend
|
|
// to apply new user changes e.g. user traits were updated.
|
|
ReloadUser bool `json:"reloadUser"`
|
|
}
|
|
|
|
// renewWebSession updates this existing session with a new session.
|
|
//
|
|
// Depending on request fields sent in for extension, the new session creation can vary depending on:
|
|
// - AccessRequestID (opt): appends roles approved from access request to currently assigned roles or,
|
|
// - Switchback (opt): roles stacked with assuming approved access requests, will revert to user's default roles
|
|
// - ReloadUser (opt): similar to default but updates user related data (e.g login traits) by retrieving it from the backend
|
|
// - default (none set): create new session with currently assigned roles
|
|
func (h *Handler) renewWebSession(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
|
|
req := renewSessionRequest{}
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if req.AccessRequestID != "" && req.Switchback || req.AccessRequestID != "" && req.ReloadUser || req.Switchback && req.ReloadUser {
|
|
return nil, trace.BadParameter("failed to renew session: only one field can be set")
|
|
}
|
|
|
|
newSession, err := ctx.extendWebSession(r.Context(), req)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
newContext, err := h.auth.newSessionContextFromSession(r.Context(), newSession)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
if err := websession.SetCookie(w, newSession.GetUser(), newSession.GetName()); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
res, err := newSessionResponse(newContext)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
res.SessionExpires = newSession.GetExpiryTime()
|
|
|
|
return res, nil
|
|
}
|
|
|
|
type changeUserAuthenticationRequest struct {
|
|
// SecondFactorToken is the TOTP code.
|
|
SecondFactorToken string `json:"second_factor_token"`
|
|
// TokenID is the ID of a reset or invite token.
|
|
TokenID string `json:"token"`
|
|
// DeviceName is the name of new mfa or passwordless device.
|
|
DeviceName string `json:"deviceName"`
|
|
// Password is user password string converted to bytes.
|
|
Password []byte `json:"password"`
|
|
// WebauthnCreationResponse is the signed credential creation response.
|
|
WebauthnCreationResponse *wantypes.CredentialCreationResponse `json:"webauthnCreationResponse"`
|
|
}
|
|
|
|
func (h *Handler) changeUserAuthentication(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
var req changeUserAuthenticationRequest
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
protoReq := &proto.ChangeUserAuthenticationRequest{
|
|
TokenID: req.TokenID,
|
|
NewPassword: req.Password,
|
|
NewDeviceName: req.DeviceName,
|
|
}
|
|
switch {
|
|
case req.WebauthnCreationResponse != nil:
|
|
protoReq.NewMFARegisterResponse = &proto.MFARegisterResponse{
|
|
Response: &proto.MFARegisterResponse_Webauthn{
|
|
Webauthn: wantypes.CredentialCreationResponseToProto(req.WebauthnCreationResponse),
|
|
},
|
|
}
|
|
case req.SecondFactorToken != "":
|
|
protoReq.NewMFARegisterResponse = &proto.MFARegisterResponse{Response: &proto.MFARegisterResponse_TOTP{
|
|
TOTP: &proto.TOTPRegisterResponse{Code: req.SecondFactorToken},
|
|
}}
|
|
}
|
|
|
|
remoteAddr, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
protoReq.LoginIP = remoteAddr
|
|
|
|
res, err := h.auth.proxyClient.ChangeUserAuthentication(r.Context(), protoReq)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if res.PrivateKeyPolicyEnabled {
|
|
if res.GetRecovery() == nil {
|
|
return &ui.ChangedUserAuthn{
|
|
PrivateKeyPolicyEnabled: res.PrivateKeyPolicyEnabled,
|
|
}, nil
|
|
}
|
|
return &ui.ChangedUserAuthn{
|
|
Recovery: ui.RecoveryCodes{
|
|
Codes: res.GetRecovery().GetCodes(),
|
|
Created: &res.GetRecovery().Created,
|
|
},
|
|
PrivateKeyPolicyEnabled: res.PrivateKeyPolicyEnabled,
|
|
}, nil
|
|
}
|
|
|
|
sess := res.WebSession
|
|
ctx, err := h.auth.newSessionContextFromSession(r.Context(), sess)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if err := websession.SetCookie(w, sess.GetUser(), sess.GetName()); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// Checks for at least one valid login.
|
|
if _, err := newSessionResponse(ctx); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if res.GetRecovery() == nil {
|
|
return &ui.ChangedUserAuthn{}, nil
|
|
}
|
|
|
|
return &ui.ChangedUserAuthn{
|
|
Recovery: ui.RecoveryCodes{
|
|
Codes: res.GetRecovery().GetCodes(),
|
|
Created: &res.GetRecovery().Created,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// createResetPasswordToken allows a UI user to reset a user's password.
|
|
// This handler is also required for after creating new users.
|
|
func (h *Handler) createResetPasswordToken(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) {
|
|
var req authclient.CreateUserTokenRequest
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clt, err := ctx.GetClient()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
token, err := clt.CreateResetPasswordToken(r.Context(),
|
|
authclient.CreateUserTokenRequest{
|
|
Name: req.Name,
|
|
Type: req.Type,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return ui.ResetPasswordToken{
|
|
TokenID: token.GetName(),
|
|
Expiry: token.Expiry(),
|
|
User: token.GetUser(),
|
|
}, nil
|
|
}
|
|
|
|
func (h *Handler) getResetPasswordTokenHandle(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
result, err := h.getResetPasswordToken(r.Context(), p.ByName("token"))
|
|
if err != nil {
|
|
h.log.WithError(err).Warn("Failed to fetch a reset password token.")
|
|
// We hide the error from the remote user to avoid giving any hints.
|
|
return nil, trace.AccessDenied("bad or expired token")
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (h *Handler) getResetPasswordToken(ctx context.Context, tokenID string) (interface{}, error) {
|
|
token, err := h.auth.proxyClient.GetResetPasswordToken(ctx, tokenID)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// CreateRegisterChallenge rotates TOTP secrets for a given tokenID.
|
|
// It is required to get called every time a user fetches 2nd-factor secrets during registration attempt.
|
|
// This ensures that an attacker that gains the ResetPasswordToken link can not view it,
|
|
// extract the OTP key from the QR code, then allow the user to signup with
|
|
// the same OTP token.
|
|
res, err := h.auth.proxyClient.CreateRegisterChallenge(ctx, &proto.CreateRegisterChallengeRequest{
|
|
TokenID: tokenID,
|
|
DeviceType: proto.DeviceType_DEVICE_TYPE_TOTP,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return ui.ResetPasswordToken{
|
|
TokenID: token.GetName(),
|
|
User: token.GetUser(),
|
|
QRCode: res.GetTOTP().GetQRCode(),
|
|
}, nil
|
|
}
|
|
|
|
// mfaLoginBegin is the first step in the MFA authentication ceremony, which
|
|
// may be completed either via mfaLoginFinish (SSH) or mfaLoginFinishSession
|
|
// (Web).
|
|
//
|
|
// POST /webapi/mfa/login/begin
|
|
//
|
|
// {"user": "alex", "pass": "abcdef123456"}
|
|
// {"passwordless": true}
|
|
//
|
|
// Successful response:
|
|
//
|
|
// {"webauthn_challenge": {...}, "totp_challenge": true}
|
|
// {"webauthn_challenge": {...}} // passwordless
|
|
func (h *Handler) mfaLoginBegin(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
var req *client.MFAChallengeRequest
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
mfaReq := &proto.CreateAuthenticateChallengeRequest{}
|
|
if req.Passwordless {
|
|
mfaReq.Request = &proto.CreateAuthenticateChallengeRequest_Passwordless{
|
|
Passwordless: &proto.Passwordless{},
|
|
}
|
|
} else {
|
|
mfaReq.Request = &proto.CreateAuthenticateChallengeRequest_UserCredentials{
|
|
UserCredentials: &proto.UserCredentials{
|
|
Username: req.User,
|
|
Password: []byte(req.Pass),
|
|
},
|
|
}
|
|
}
|
|
|
|
mfaChallenge, err := h.auth.proxyClient.CreateAuthenticateChallenge(r.Context(), mfaReq)
|
|
if err != nil {
|
|
// Do not obfuscate config-related errors.
|
|
if errors.Is(err, types.ErrPasswordlessRequiresWebauthn) || errors.Is(err, types.ErrPasswordlessDisabledBySettings) {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return nil, trace.AccessDenied("invalid credentials")
|
|
}
|
|
|
|
return makeAuthenticateChallenge(mfaChallenge), nil
|
|
}
|
|
|
|
// mfaLoginFinish completes the MFA login ceremony, returning a new SSH
|
|
// certificate if successful.
|
|
//
|
|
// POST /v1/mfa/login/finish
|
|
//
|
|
// { "user": "bob", "password": "pass", "pub_key": "key to sign", "ttl": 1000000000 } # password-only
|
|
// { "user": "bob", "webauthn_challenge_response": {...}, "pub_key": "key to sign", "ttl": 1000000000 } # mfa
|
|
//
|
|
// # Success response
|
|
//
|
|
// { "cert": "base64 encoded signed cert", "host_signers": [{"domain_name": "example.com", "checking_keys": ["base64 encoded public signing key"]}] }
|
|
func (h *Handler) mfaLoginFinish(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
var req *client.AuthenticateSSHUserRequest
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clientMeta := clientMetaFromReq(r)
|
|
cert, err := h.auth.AuthenticateSSHUser(r.Context(), *req, clientMeta)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return cert, nil
|
|
}
|
|
|
|
// mfaLoginFinishSession completes the MFA login ceremony, returning a new web
|
|
// session if successful.
|
|
//
|
|
// POST /webapi/mfa/login/finishsession
|
|
//
|
|
// {"user": "alex", "webauthn_challenge_response": {...}}
|
|
//
|
|
// Successful response:
|
|
//
|
|
// {"type": "bearer", "token": "bearer token", "user": {"name": "alex", "allowed_logins": ["admin", "bob"]}, "expires_in": 20}
|
|
func (h *Handler) mfaLoginFinishSession(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
req := &client.AuthenticateWebUserRequest{}
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clientMeta := clientMetaFromReq(r)
|
|
session, err := h.auth.AuthenticateWebUser(r.Context(), req, clientMeta)
|
|
switch {
|
|
// Since checking for private key policy meant that they passed authn,
|
|
// return policy error as is to help direct user.
|
|
case keys.IsPrivateKeyPolicyError(err):
|
|
return nil, trace.Wrap(err)
|
|
|
|
// Return a friendlier error if an SSO user tried to do passwordless.
|
|
case errors.Is(err, types.ErrPassswordlessLoginBySSOUser):
|
|
return nil, trace.Wrap(err)
|
|
|
|
// Obscure all other errors.
|
|
case err != nil:
|
|
return nil, trace.AccessDenied("invalid credentials")
|
|
}
|
|
|
|
// Fetch user from session, user is empty for passwordless requests.
|
|
user := session.GetUser()
|
|
if err := websession.SetCookie(w, user, session.GetName()); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
ctx, err := h.auth.newSessionContextFromSession(r.Context(), session)
|
|
if err != nil {
|
|
return nil, trace.AccessDenied("need auth")
|
|
}
|
|
|
|
return newSessionResponse(ctx)
|
|
}
|
|
|
|
// getClusters returns a list of cluster and its data.
|
|
//
|
|
// GET /v1/webapi/sites
|
|
//
|
|
// Successful response:
|
|
//
|
|
// {"sites": {"name": "localhost", "last_connected": "RFC3339 time", "status": "active"}}
|
|
func (h *Handler) getClusters(w http.ResponseWriter, r *http.Request, p httprouter.Params, c *SessionContext) (interface{}, error) {
|
|
// Get a client to the Auth Server with the logged in users identity. The
|
|
// identity of the logged in user is used to fetch the list of nodes.
|
|
clt, err := c.GetClient()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
remoteClusters, err := clt.GetRemoteClusters(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
clusterName, err := clt.GetClusterName()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
rc, err := types.NewRemoteCluster(clusterName.GetClusterName())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
rc.SetLastHeartbeat(time.Now().UTC())
|
|
rc.SetConnectionStatus(teleport.RemoteClusterStatusOnline)
|
|
clusters := make([]types.RemoteCluster, 0, len(remoteClusters)+1)
|
|
clusters = append(clusters, rc)
|
|
clusters = append(clusters, remoteClusters...)
|
|
out, err := ui.NewClustersFromRemote(clusters)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type getSiteNamespacesResponse struct {
|
|
Namespaces []types.Namespace `json:"namespaces"`
|
|
}
|
|
|
|
// getSiteNamespaces returns a list of namespaces for a given site
|
|
//
|
|
// GET /v1/webapi/sites/:site/namespaces
|
|
//
|
|
// Successful response:
|
|
//
|
|
// {"namespaces": [{..namespace resource...}]}
|
|
func (h *Handler) getSiteNamespaces(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
clt, err := site.GetClient()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
namespaces, err := clt.GetNamespaces()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return getSiteNamespacesResponse{
|
|
Namespaces: namespaces,
|
|
}, nil
|
|
}
|
|
|
|
func makeUnifiedResourceRequest(r *http.Request) (*proto.ListUnifiedResourcesRequest, error) {
|
|
values := r.URL.Query()
|
|
|
|
limit, err := QueryLimitAsInt32(values, "limit", defaults.MaxIterationLimit)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
sortBy := types.GetSortByFromString(values.Get("sort"))
|
|
|
|
var kinds []string
|
|
for _, kind := range values["kinds"] {
|
|
if kind != "" {
|
|
kinds = append(kinds, kind)
|
|
}
|
|
}
|
|
|
|
// set default kinds to be requested if none exist in the request
|
|
if len(kinds) == 0 {
|
|
kinds = []string{
|
|
types.KindApp,
|
|
types.KindDatabase,
|
|
types.KindNode,
|
|
types.KindWindowsDesktop,
|
|
types.KindKubernetesCluster,
|
|
}
|
|
}
|
|
|
|
startKey := values.Get("startKey")
|
|
includeRequestable := values.Get("includeRequestable") == "true"
|
|
return &proto.ListUnifiedResourcesRequest{
|
|
Kinds: kinds,
|
|
Limit: limit,
|
|
StartKey: startKey,
|
|
SortBy: sortBy,
|
|
PinnedOnly: values.Get("pinnedOnly") == "true",
|
|
PredicateExpression: values.Get("query"),
|
|
SearchKeywords: client.ParseSearchKeywords(values.Get("search"), ' '),
|
|
// includeRequestable requires a searchAsRoles request, but we set it here instead of the frontend
|
|
// to protect the frontend from accidentally requesting a "SearchAsRoles" request to older proxy versions
|
|
// and then returning a bunch of resources that won't include "Requires Request" flags.
|
|
UseSearchAsRoles: values.Get("searchAsRoles") == "yes" || includeRequestable,
|
|
IncludeLogins: true,
|
|
IncludeRequestable: includeRequestable,
|
|
}, nil
|
|
}
|
|
|
|
type loginGetter interface {
|
|
GetAllowedLoginsForResource(resource services.AccessCheckable) ([]string, error)
|
|
}
|
|
|
|
// calculateSSHLogins returns the subset of the allowedLogins that exist in
|
|
// the principals of the identity. This is required because SSH authorization
|
|
// only allows using a login that exists in the certificates valid principals.
|
|
// When connecting to servers in a leaf cluster, the root certificate is used,
|
|
// so we need to ensure that we only present the allowed logins that would
|
|
// result in a successful connection, if any exists.
|
|
func calculateSSHLogins(identity *tlsca.Identity, loginGetter loginGetter, r types.ResourceWithLabels, allowedLogins []string) ([]string, error) {
|
|
// TODO(tross) DELETE IN V17.0.0
|
|
// This is here for backward compatibility in case the auth server
|
|
// does not support enriched resources yet.
|
|
if len(allowedLogins) == 0 {
|
|
logins, err := loginGetter.GetAllowedLoginsForResource(r)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
slices.Sort(logins)
|
|
return logins, nil
|
|
}
|
|
|
|
localLogins := identity.Principals
|
|
|
|
allowed := make(map[string]struct{})
|
|
for _, login := range allowedLogins {
|
|
allowed[login] = struct{}{}
|
|
}
|
|
|
|
var logins []string
|
|
for _, local := range localLogins {
|
|
if _, ok := allowed[local]; ok {
|
|
logins = append(logins, local)
|
|
}
|
|
}
|
|
|
|
slices.Sort(logins)
|
|
return logins, nil
|
|
}
|
|
|
|
// calculateDesktopLogins determines the desktop logins allowed for the provided resource.
|
|
// If no logins are provided, then the checker is interrogated to determine which logins
|
|
// are allowed.
|
|
//
|
|
// TODO(tross) DELETE IN V17.0.0
|
|
// This is here for backward compatibility if the auth server doesn't yet support enriching
|
|
// resources with login information.
|
|
func calculateDesktopLogins(loginGetter loginGetter, r types.ResourceWithLabels, allowedLogins []string) ([]string, error) {
|
|
if len(allowedLogins) > 0 {
|
|
return allowedLogins, nil
|
|
}
|
|
|
|
logins, err := loginGetter.GetAllowedLoginsForResource(r)
|
|
return logins, trace.Wrap(err)
|
|
}
|
|
|
|
// getUserGroupLookup is a generator to retrieve UserGroupLookup on first call and return it again in subsequent calls.
|
|
// If we encounter an error, we log it once and return an empty UserGroupLookup for the current and subsequent calls.
|
|
// The returned function is not thread safe.
|
|
func (h *Handler) getUserGroupLookup(ctx context.Context, clt apiclient.GetResourcesClient) func() map[string]types.UserGroup {
|
|
userGroupLookup := make(map[string]types.UserGroup)
|
|
var gotUserGroupLookup bool
|
|
return func() map[string]types.UserGroup {
|
|
if gotUserGroupLookup {
|
|
return userGroupLookup
|
|
}
|
|
|
|
userGroups, err := apiclient.GetAllResources[types.UserGroup](ctx, clt, &proto.ListResourcesRequest{
|
|
ResourceType: types.KindUserGroup,
|
|
Namespace: apidefaults.Namespace,
|
|
UseSearchAsRoles: true,
|
|
})
|
|
if err != nil {
|
|
h.log.Infof("Unable to fetch user groups while listing applications, unable to display associated user groups: %v", err)
|
|
}
|
|
|
|
for _, userGroup := range userGroups {
|
|
userGroupLookup[userGroup.GetName()] = userGroup
|
|
}
|
|
|
|
gotUserGroupLookup = true
|
|
return userGroupLookup
|
|
}
|
|
}
|
|
|
|
// clusterUnifiedResourcesGet returns a list of resources for a given cluster site. This includes all resources available to be displayed in the web ui
|
|
// such as Nodes, Apps, Desktops, etc etc
|
|
func (h *Handler) clusterUnifiedResourcesGet(w http.ResponseWriter, request *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
clt, err := sctx.GetUserClient(request.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
identity, err := sctx.GetIdentity()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
req, err := makeUnifiedResourceRequest(request)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
page, next, err := apiclient.GetUnifiedResourcePage(request.Context(), clt, req)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
accessChecker, err := sctx.GetUserAccessChecker()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
getUserGroupLookup := h.getUserGroupLookup(request.Context(), clt)
|
|
|
|
var dbNames, dbUsers []string
|
|
hasFetchedDBUsersAndNames := false
|
|
|
|
unifiedResources := make([]any, 0, len(page))
|
|
for _, enriched := range page {
|
|
switch r := enriched.ResourceWithLabels.(type) {
|
|
case types.Server:
|
|
logins, err := calculateSSHLogins(identity, accessChecker, r, enriched.Logins)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
unifiedResources = append(unifiedResources, ui.MakeServer(site.GetName(), r, logins, enriched.RequiresRequest))
|
|
case types.DatabaseServer:
|
|
if !hasFetchedDBUsersAndNames {
|
|
dbNames, dbUsers, err = getDatabaseUsersAndNames(accessChecker)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
hasFetchedDBUsersAndNames = true
|
|
}
|
|
db := ui.MakeDatabase(r.GetDatabase(), dbUsers, dbNames, enriched.RequiresRequest)
|
|
unifiedResources = append(unifiedResources, db)
|
|
case types.AppServer:
|
|
allowedAWSRoles, err := accessChecker.GetAllowedLoginsForResource(r.GetApp())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
allowedAWSRolesLookup := map[string][]string{
|
|
r.GetApp().GetName(): allowedAWSRoles,
|
|
}
|
|
|
|
app := ui.MakeApp(r.GetApp(), ui.MakeAppsConfig{
|
|
LocalClusterName: h.auth.clusterName,
|
|
LocalProxyDNSName: h.proxyDNSName(),
|
|
AppClusterName: site.GetName(),
|
|
AllowedAWSRolesLookup: allowedAWSRolesLookup,
|
|
UserGroupLookup: getUserGroupLookup(),
|
|
Logger: h.log,
|
|
RequiresRequest: enriched.RequiresRequest,
|
|
})
|
|
unifiedResources = append(unifiedResources, app)
|
|
case types.AppServerOrSAMLIdPServiceProvider:
|
|
//nolint:staticcheck // SA1019. TODO(sshah) DELETE IN 17.0
|
|
if r.IsAppServer() {
|
|
allowedAWSRoles, err := accessChecker.GetAllowedLoginsForResource(r.GetAppServer().GetApp())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
allowedAWSRolesLookup := map[string][]string{
|
|
r.GetAppServer().GetApp().GetName(): allowedAWSRoles,
|
|
}
|
|
app := ui.MakeApp(r.GetAppServer().GetApp(), ui.MakeAppsConfig{
|
|
LocalClusterName: h.auth.clusterName,
|
|
LocalProxyDNSName: h.proxyDNSName(),
|
|
AppClusterName: site.GetName(),
|
|
AllowedAWSRolesLookup: allowedAWSRolesLookup,
|
|
UserGroupLookup: getUserGroupLookup(),
|
|
Logger: h.log,
|
|
RequiresRequest: enriched.RequiresRequest,
|
|
})
|
|
unifiedResources = append(unifiedResources, app)
|
|
} else {
|
|
app := ui.MakeAppTypeFromSAMLApp(r.GetSAMLIdPServiceProvider(), ui.MakeAppsConfig{
|
|
LocalClusterName: h.auth.clusterName,
|
|
LocalProxyDNSName: h.proxyDNSName(),
|
|
AppClusterName: site.GetName(),
|
|
RequiresRequest: enriched.RequiresRequest,
|
|
})
|
|
unifiedResources = append(unifiedResources, app)
|
|
}
|
|
case types.SAMLIdPServiceProvider:
|
|
// SAMLIdPServiceProvider resources are shown as
|
|
// "apps" in the UI.
|
|
app := ui.MakeAppTypeFromSAMLApp(r, ui.MakeAppsConfig{
|
|
LocalClusterName: h.auth.clusterName,
|
|
LocalProxyDNSName: h.proxyDNSName(),
|
|
AppClusterName: site.GetName(),
|
|
RequiresRequest: enriched.RequiresRequest,
|
|
})
|
|
unifiedResources = append(unifiedResources, app)
|
|
case types.WindowsDesktop:
|
|
logins, err := calculateDesktopLogins(accessChecker, r, enriched.Logins)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
unifiedResources = append(unifiedResources, ui.MakeDesktop(r, logins, enriched.RequiresRequest))
|
|
case types.KubeCluster:
|
|
kube := ui.MakeKubeCluster(r, accessChecker, enriched.RequiresRequest)
|
|
unifiedResources = append(unifiedResources, kube)
|
|
case types.KubeServer:
|
|
kube := ui.MakeKubeCluster(r.GetCluster(), accessChecker, enriched.RequiresRequest)
|
|
unifiedResources = append(unifiedResources, kube)
|
|
default:
|
|
return nil, trace.Errorf("UI Resource has unknown type: %T", enriched)
|
|
}
|
|
}
|
|
|
|
resp := listResourcesGetResponse{
|
|
Items: unifiedResources,
|
|
StartKey: next,
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// clusterNodesGet returns a list of nodes for a given cluster site.
|
|
func (h *Handler) clusterNodesGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
// Get a client to the Auth Server with the logged in user's identity. The
|
|
// identity of the logged in user is used to fetch the list of nodes.
|
|
clt, err := sctx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
req, err := convertListResourcesRequest(r, types.KindNode)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
req.IncludeLogins = true
|
|
|
|
page, err := apiclient.GetEnrichedResourcePage(r.Context(), clt, req)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
identity, err := sctx.GetIdentity()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
accessChecker, err := sctx.GetUserAccessChecker()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
uiServers := make([]ui.Server, 0, len(page.Resources))
|
|
for _, resource := range page.Resources {
|
|
server, ok := resource.ResourceWithLabels.(types.Server)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
logins, err := calculateSSHLogins(identity, accessChecker, server, resource.Logins)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
uiServers = append(uiServers, ui.MakeServer(site.GetName(), server, logins, false /* requiresRequest */))
|
|
}
|
|
|
|
return listResourcesGetResponse{
|
|
Items: uiServers,
|
|
StartKey: page.NextKey,
|
|
TotalCount: page.Total,
|
|
}, nil
|
|
}
|
|
|
|
// iso8601MilliFormat is the time format of dates returned from the frontend using Date().
|
|
const iso8601MilliFormat = "2006-01-02T15:04:05.000Z0700"
|
|
|
|
// notificationsGet returns a paginated list of notifications for a user.
|
|
func (h *Handler) notificationsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
clt, err := sctx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
values := r.URL.Query()
|
|
|
|
limit, err := QueryLimitAsInt32(values, "limit", defaults.MaxIterationLimit)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
startKey := values.Get("startKey")
|
|
|
|
response, err := clt.NotificationServiceClient().ListNotifications(r.Context(), ¬ificationsv1.ListNotificationsRequest{
|
|
PageSize: limit,
|
|
PageToken: startKey,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
var uiNotifications []ui.Notification
|
|
|
|
for _, notification := range response.Notifications {
|
|
uiNotif := ui.MakeNotification(notification)
|
|
uiNotifications = append(uiNotifications, uiNotif)
|
|
}
|
|
|
|
return GetNotificationsResponse{
|
|
Notifications: uiNotifications,
|
|
NextKey: response.NextPageToken,
|
|
UserLastSeenNotification: response.UserLastSeenNotificationTimestamp.AsTime().Format(iso8601MilliFormat),
|
|
}, nil
|
|
}
|
|
|
|
type GetNotificationsResponse struct {
|
|
Notifications []ui.Notification `json:"notifications"`
|
|
NextKey string `json:"nextKey"`
|
|
UserLastSeenNotification string `json:"userLastSeenNotification"`
|
|
}
|
|
|
|
// notificationsUpsertLastSeenTimestamp upserts a user's last seen notification timestamp.
|
|
func (h *Handler) notificationsUpsertLastSeenTimestamp(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
clt, err := sctx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
var req *UpsertUserLastSeenNotificationRequest
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
lastSeenTime, err := time.Parse(iso8601MilliFormat, req.Time)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
resp, err := clt.NotificationServiceClient().UpsertUserLastSeenNotification(r.Context(), ¬ificationsv1.UpsertUserLastSeenNotificationRequest{
|
|
Username: sctx.GetUser(),
|
|
UserLastSeenNotification: ¬ificationsv1.UserLastSeenNotification{
|
|
Status: ¬ificationsv1.UserLastSeenNotificationStatus{
|
|
LastSeenTime: timestamppb.New(lastSeenTime),
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return &UpsertUserLastSeenNotificationRequest{
|
|
Time: resp.Status.LastSeenTime.AsTime().Format(iso8601MilliFormat),
|
|
}, nil
|
|
}
|
|
|
|
type UpsertUserLastSeenNotificationRequest struct {
|
|
Time string `json:"time"`
|
|
}
|
|
|
|
// notificationsUpsertNotificationState upserts a user notification state.
|
|
func (h *Handler) notificationsUpsertNotificationState(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
clt, err := sctx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
var req *upsertUserNotificationStateRequest
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
resp, err := clt.NotificationServiceClient().UpsertUserNotificationState(r.Context(), ¬ificationsv1.UpsertUserNotificationStateRequest{
|
|
Username: sctx.GetUser(),
|
|
UserNotificationState: ¬ificationsv1.UserNotificationState{
|
|
Spec: ¬ificationsv1.UserNotificationStateSpec{
|
|
NotificationId: req.NotificationId,
|
|
},
|
|
Status: ¬ificationsv1.UserNotificationStateStatus{
|
|
NotificationState: req.NotificationState,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return &upsertUserNotificationStateRequest{
|
|
NotificationId: resp.GetSpec().GetNotificationId(),
|
|
NotificationState: resp.GetStatus().GetNotificationState(),
|
|
}, nil
|
|
}
|
|
|
|
type upsertUserNotificationStateRequest struct {
|
|
NotificationId string `json:"notificationId"`
|
|
NotificationState notificationsv1.NotificationState `json:"notificationState"`
|
|
}
|
|
|
|
type getLoginAlertsResponse struct {
|
|
Alerts []types.ClusterAlert `json:"alerts"`
|
|
}
|
|
|
|
// clusterLoginAlertsGet returns a list of on-login alerts for the user.
|
|
func (h *Handler) clusterLoginAlertsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
// Get a client to the Auth Server with the logged in user's identity. The
|
|
// identity of the logged in user is used to fetch the list of alerts.
|
|
clt, err := sctx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
alerts, err := clt.GetClusterAlerts(h.cfg.Context, types.GetClusterAlertsRequest{
|
|
Labels: map[string]string{
|
|
types.AlertOnLogin: "yes",
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return getLoginAlertsResponse{
|
|
Alerts: alerts,
|
|
}, nil
|
|
}
|
|
|
|
func (h *Handler) getClusterLocks(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
p httprouter.Params,
|
|
sessionCtx *SessionContext,
|
|
site reversetunnelclient.RemoteSite,
|
|
) (interface{}, error) {
|
|
ctx := r.Context()
|
|
clt, err := sessionCtx.GetUserClient(ctx, site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
locks, err := clt.GetLocks(ctx, false)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return ui.MakeLocks(locks), nil
|
|
}
|
|
|
|
type createLockReq struct {
|
|
Targets types.LockTarget `json:"targets"`
|
|
Message string `json:"message"`
|
|
TTL string `json:"ttl"`
|
|
}
|
|
|
|
func (h *Handler) createClusterLock(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
p httprouter.Params,
|
|
sessionCtx *SessionContext,
|
|
site reversetunnelclient.RemoteSite,
|
|
) (interface{}, error) {
|
|
var req *createLockReq
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
ctx := r.Context()
|
|
clt, err := sessionCtx.GetUserClient(ctx, site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
var ttlDuration time.Duration
|
|
if req.TTL != "" {
|
|
ttlDuration, err = time.ParseDuration(req.TTL)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
var expires *time.Time
|
|
if ttlDuration != 0 {
|
|
t := time.Now().UTC().Add(ttlDuration)
|
|
expires = &t
|
|
}
|
|
|
|
lock, err := types.NewLock(uuid.New().String(), types.LockSpecV2{
|
|
Target: req.Targets,
|
|
Message: req.Message,
|
|
Expires: expires,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
err = clt.UpsertLock(ctx, lock)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return ui.MakeLock(lock), nil
|
|
}
|
|
|
|
func (h *Handler) deleteClusterLock(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
p httprouter.Params,
|
|
sessionCtx *SessionContext,
|
|
site reversetunnelclient.RemoteSite,
|
|
) (interface{}, error) {
|
|
ctx := r.Context()
|
|
clt, err := sessionCtx.GetUserClient(ctx, site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
err = clt.DeleteLock(ctx, p.ByName("uuid"))
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return OK(), nil
|
|
}
|
|
|
|
// SessionController restricts creation of sessions based on
|
|
// cluster session control configuration(locks, connection limits, etc).
|
|
type SessionController interface {
|
|
// AcquireSessionContext attempts to create a context for the session. If the session is
|
|
// not allowed due to session control an error is returned. The returned
|
|
// context is scoped to the session and will be canceled in the event that session
|
|
// controls terminate the session early.
|
|
AcquireSessionContext(ctx context.Context, sctx *SessionContext, login, localAddr, remoteAddr string) (context.Context, error)
|
|
}
|
|
|
|
// SessionControllerFunc type is an adapter to allow the use of
|
|
// ordinary functions a [SessionController]. If f is a function
|
|
// with the appropriate signature, SessionControllerFunc(f) is a
|
|
// SessionController that calls f.
|
|
type SessionControllerFunc func(ctx context.Context, sctx *SessionContext, login, localAddr, remoteAddr string) (context.Context, error)
|
|
|
|
// AcquireSessionContext calls f(ctx, sctx, localAddr, remoteAddr).
|
|
func (f SessionControllerFunc) AcquireSessionContext(ctx context.Context, sctx *SessionContext, login, localAddr, remoteAddr string) (context.Context, error) {
|
|
ctx, err := f(ctx, sctx, login, localAddr, remoteAddr)
|
|
return ctx, trace.Wrap(err)
|
|
}
|
|
|
|
// siteNodeConnect connect to the site node
|
|
//
|
|
// GET /v1/webapi/sites/:site/namespaces/:namespace/connect?access_token=bearer_token¶ms=<urlencoded json-structure>
|
|
//
|
|
// Due to the nature of websocket we can't POST parameters as is, so we have
|
|
// to add query parameters. The params query parameter is a URL-encoded JSON structure:
|
|
//
|
|
// {"server_id": "uuid", "login": "admin", "term": {"h": 120, "w": 100}, "sid": "123"}
|
|
//
|
|
// Successful response is a websocket stream that allows read write to the server
|
|
func (h *Handler) siteNodeConnect(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
p httprouter.Params,
|
|
sessionCtx *SessionContext,
|
|
site reversetunnelclient.RemoteSite,
|
|
ws *websocket.Conn,
|
|
) (interface{}, error) {
|
|
q := r.URL.Query()
|
|
params := q.Get("params")
|
|
if params == "" {
|
|
return nil, trace.BadParameter("missing params")
|
|
}
|
|
var req TerminalRequest
|
|
if err := json.Unmarshal([]byte(params), &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clt, err := sessionCtx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
ctx, err := h.cfg.SessionControl.AcquireSessionContext(r.Context(), sessionCtx, req.Login, h.cfg.ProxyWebAddr.Addr, r.RemoteAddr)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
var (
|
|
sessionData session.Session
|
|
displayLogin string
|
|
tracker types.SessionTracker
|
|
)
|
|
|
|
clusterName := site.GetName()
|
|
if req.SessionID.IsZero() {
|
|
// An existing session ID was not provided so we need to create a new one.
|
|
sessionData, err = h.generateSession(&req, clusterName, sessionCtx)
|
|
if err != nil {
|
|
h.log.WithError(err).Debug("Unable to generate new ssh session.")
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
} else {
|
|
sessionData, tracker, err = h.fetchExistingSession(ctx, clt, &req, clusterName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
displayLogin = tracker.GetLogin()
|
|
}
|
|
|
|
// If the participantMode is not specified, and the user is the one who created the session,
|
|
// they should be in Peer mode. If not, default to Observer mode.
|
|
if req.ParticipantMode == "" {
|
|
if sessionData.Owner == sessionCtx.cfg.User {
|
|
req.ParticipantMode = types.SessionPeerMode
|
|
} else {
|
|
req.ParticipantMode = types.SessionObserverMode
|
|
}
|
|
}
|
|
|
|
h.log.Debugf("New terminal request for server=%s, login=%s, sid=%s, websid=%s.",
|
|
req.Server, req.Login, sessionData.ID, sessionCtx.GetSessionID())
|
|
|
|
keepAliveInterval := req.KeepAliveInterval
|
|
// Try to use the keep alive interval from the request.
|
|
// When it's not set or below a second, use the cluster's keep alive interval.
|
|
if keepAliveInterval < time.Second {
|
|
authAccessPoint, err := site.CachingAccessPoint()
|
|
if err != nil {
|
|
h.log.Debugf("Unable to get auth access point: %v", err)
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
netConfig, err := authAccessPoint.GetClusterNetworkingConfig(ctx)
|
|
if err != nil {
|
|
h.log.WithError(err).Debug("Unable to fetch cluster networking config.")
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
keepAliveInterval = netConfig.GetKeepAliveInterval()
|
|
}
|
|
|
|
nw, err := site.NodeWatcher()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
term, err := NewTerminal(ctx, TerminalHandlerConfig{
|
|
Term: req.Term,
|
|
SessionCtx: sessionCtx,
|
|
UserAuthClient: clt,
|
|
LocalAccessPoint: h.auth.accessPoint,
|
|
DisplayLogin: displayLogin,
|
|
SessionData: sessionData,
|
|
KeepAliveInterval: keepAliveInterval,
|
|
ProxyHostPort: h.ProxyHostPort(),
|
|
ProxyPublicAddr: h.PublicProxyAddr(),
|
|
InteractiveCommand: req.InteractiveCommand,
|
|
Router: h.cfg.Router,
|
|
TracerProvider: h.cfg.TracerProvider,
|
|
ParticipantMode: req.ParticipantMode,
|
|
PROXYSigner: h.cfg.PROXYSigner,
|
|
Tracker: tracker,
|
|
PresenceChecker: h.cfg.PresenceChecker,
|
|
WebsocketConn: ws,
|
|
HostNameResolver: func(serverID string) (string, error) {
|
|
matches := nw.GetNodes(r.Context(), func(n services.Node) bool {
|
|
return n.GetName() == serverID
|
|
})
|
|
|
|
if len(matches) != 1 {
|
|
return "", trace.NotFound("unable to resolve hostname for server %s", serverID)
|
|
}
|
|
|
|
return matches[0].GetHostname(), nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
h.log.WithError(err).Error("Unable to create terminal.")
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
h.userConns.Add(1)
|
|
defer h.userConns.Add(-1)
|
|
|
|
// start the websocket session with a web-based terminal:
|
|
h.log.Infof("Getting terminal to %#v.", req)
|
|
httplib.MakeTracingHandler(term, teleport.ComponentProxy).ServeHTTP(w, r)
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (h *Handler) podConnect(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
p httprouter.Params,
|
|
sctx *SessionContext,
|
|
site reversetunnelclient.RemoteSite,
|
|
ws *websocket.Conn,
|
|
) (interface{}, error) {
|
|
q := r.URL.Query()
|
|
params := q.Get("params")
|
|
if params == "" {
|
|
return nil, trace.BadParameter("missing params")
|
|
}
|
|
|
|
var execReq PodExecRequest
|
|
if err := json.Unmarshal([]byte(params), &execReq); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clt, err := sctx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clusterName := site.GetName()
|
|
|
|
accessChecker, err := sctx.GetUserAccessChecker()
|
|
if err != nil {
|
|
return session.Session{}, trace.Wrap(err)
|
|
}
|
|
policySets := accessChecker.SessionPolicySets()
|
|
accessEvaluator := auth.NewSessionAccessEvaluator(policySets, types.KubernetesSessionKind, sctx.GetUser())
|
|
|
|
sess := session.Session{
|
|
Kind: types.KubernetesSessionKind,
|
|
Login: sctx.GetUser(),
|
|
ClusterName: clusterName,
|
|
KubernetesClusterName: execReq.KubeCluster,
|
|
Moderated: accessEvaluator.IsModerated(),
|
|
ID: session.NewID(),
|
|
Created: h.clock.Now().UTC(),
|
|
LastActive: h.clock.Now().UTC(),
|
|
Namespace: apidefaults.Namespace,
|
|
Owner: sctx.GetUser(),
|
|
}
|
|
|
|
h.log.Debugf("New kube exec request for namespace=%s pod=%s container=%s, sid=%s, websid=%s.",
|
|
execReq.Namespace, execReq.Pod, execReq.Container, sess.ID, sctx.GetSessionID())
|
|
|
|
authAccessPoint, err := site.CachingAccessPoint()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
netConfig, err := authAccessPoint.GetClusterNetworkingConfig(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
keepAliveInterval := netConfig.GetKeepAliveInterval()
|
|
|
|
serverAddr, tlsServerName, err := h.getKubeExecClusterData(netConfig)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
hostCA, err := h.auth.accessPoint.GetCertAuthority(r.Context(), types.CertAuthID{
|
|
Type: types.HostCA,
|
|
DomainName: h.auth.clusterName,
|
|
}, false)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
ph := podHandler{
|
|
req: execReq,
|
|
sess: sess,
|
|
sctx: sctx,
|
|
teleportCluster: site.GetName(),
|
|
ws: ws,
|
|
keepAliveInterval: keepAliveInterval,
|
|
log: h.log.WithField(teleport.ComponentKey, "pod"),
|
|
userClient: clt,
|
|
localCA: hostCA,
|
|
configServerAddr: serverAddr,
|
|
configTLSServerName: tlsServerName,
|
|
}
|
|
|
|
ph.ServeHTTP(w, r)
|
|
return nil, nil
|
|
}
|
|
|
|
func (h *Handler) getKubeExecClusterData(netConfig types.ClusterNetworkingConfig) (string, string, error) {
|
|
if netConfig.GetProxyListenerMode() == types.ProxyListenerMode_Separate {
|
|
return "https://" + h.kubeProxyHostPort(), "", nil
|
|
}
|
|
|
|
proxyAddr := createHostPort(h.cfg.ProxyWebAddr, defaults.HTTPListenPort)
|
|
host, port, err := utils.SplitHostPort(proxyAddr)
|
|
if err != nil {
|
|
return "", "", trace.Wrap(err, "failed to split proxy address %q", proxyAddr)
|
|
}
|
|
|
|
tlsServerName := client.GetKubeTLSServerName(host)
|
|
|
|
return "https://" + net.JoinHostPort(host, port), tlsServerName, nil
|
|
}
|
|
|
|
func (h *Handler) generateSession(req *TerminalRequest, clusterName string, scx *SessionContext) (session.Session, error) {
|
|
owner := scx.cfg.User
|
|
h.log.Infof("Generating new session for %s\n", clusterName)
|
|
|
|
host, port, err := serverHostPort(req.Server)
|
|
if err != nil {
|
|
return session.Session{}, trace.Wrap(err)
|
|
}
|
|
|
|
accessChecker, err := scx.GetUserAccessChecker()
|
|
if err != nil {
|
|
return session.Session{}, trace.Wrap(err)
|
|
}
|
|
policySets := accessChecker.SessionPolicySets()
|
|
accessEvaluator := auth.NewSessionAccessEvaluator(policySets, types.SSHSessionKind, owner)
|
|
|
|
return session.Session{
|
|
Kind: types.SSHSessionKind,
|
|
Login: req.Login,
|
|
ServerID: host,
|
|
ClusterName: clusterName,
|
|
ServerHostname: host,
|
|
ServerHostPort: port,
|
|
Moderated: accessEvaluator.IsModerated(),
|
|
ID: session.NewID(),
|
|
Created: time.Now().UTC(),
|
|
LastActive: time.Now().UTC(),
|
|
Namespace: apidefaults.Namespace,
|
|
Owner: owner,
|
|
}, nil
|
|
}
|
|
|
|
// fetchExistingSession fetches an active or pending SSH session by the SessionID passed in the TerminalRequest.
|
|
func (h *Handler) fetchExistingSession(ctx context.Context, clt authclient.ClientI, req *TerminalRequest, siteName string) (session.Session, types.SessionTracker, error) {
|
|
sessionID, err := session.ParseID(req.SessionID.String())
|
|
if err != nil {
|
|
return session.Session{}, nil, trace.Wrap(err)
|
|
}
|
|
h.log.Infof("Attempting to join existing session: %s\n", sessionID)
|
|
|
|
tracker, err := clt.GetSessionTracker(ctx, string(*sessionID))
|
|
if err != nil {
|
|
return session.Session{}, nil, trace.Wrap(err)
|
|
}
|
|
|
|
if tracker.GetSessionKind() != types.SSHSessionKind || tracker.GetState() == types.SessionState_SessionStateTerminated {
|
|
return session.Session{}, nil, trace.NotFound("SSH session %v not found", sessionID)
|
|
}
|
|
|
|
sessionData := trackerToLegacySession(tracker, siteName)
|
|
// When joining an existing session use the specially handled
|
|
// `SSHSessionJoinPrincipal` login instead of the provided login so that
|
|
// users are able to join sessions without having permissions to create
|
|
// new ones themselves for auditing purposes. Otherwise, the user would
|
|
// fail the SSH lib username validation step.
|
|
sessionData.Login = teleport.SSHSessionJoinPrincipal
|
|
|
|
return sessionData, tracker, nil
|
|
}
|
|
|
|
type siteSessionGenerateResponse struct {
|
|
Session session.Session `json:"session"`
|
|
}
|
|
|
|
type siteSessionsGetResponse struct {
|
|
Sessions []siteSessionsGetResponseSession `json:"sessions"`
|
|
}
|
|
|
|
type siteSessionsGetResponseSession struct {
|
|
session.Session
|
|
ParticipantModes []types.SessionParticipantMode `json:"participantModes"`
|
|
}
|
|
|
|
func trackerToLegacySession(tracker types.SessionTracker, clusterName string) session.Session {
|
|
participants := tracker.GetParticipants()
|
|
parties := make([]session.Party, 0, len(participants))
|
|
|
|
for _, participant := range participants {
|
|
parties = append(parties, session.Party{
|
|
ID: session.ID(participant.ID),
|
|
User: participant.User,
|
|
ServerID: tracker.GetAddress(),
|
|
LastActive: participant.LastActive,
|
|
// note: we don't populate the RemoteAddr field since it isn't used and we don't have an equivalent value
|
|
})
|
|
}
|
|
accessEvaluator := auth.NewSessionAccessEvaluator(tracker.GetHostPolicySets(), types.SSHSessionKind, tracker.GetHostUser())
|
|
|
|
return session.Session{
|
|
Kind: tracker.GetSessionKind(),
|
|
ID: session.ID(tracker.GetSessionID()),
|
|
Namespace: apidefaults.Namespace,
|
|
Parties: parties,
|
|
TerminalParams: session.TerminalParams{
|
|
W: teleport.DefaultTerminalWidth,
|
|
H: teleport.DefaultTerminalHeight,
|
|
},
|
|
Login: tracker.GetLogin(),
|
|
Created: tracker.GetCreated(),
|
|
LastActive: tracker.GetLastActive(),
|
|
ServerID: tracker.GetAddress(),
|
|
ServerHostname: tracker.GetHostname(),
|
|
ServerAddr: tracker.GetAddress(),
|
|
ClusterName: clusterName,
|
|
KubernetesClusterName: tracker.GetKubeCluster(),
|
|
DesktopName: tracker.GetDesktopName(),
|
|
AppName: tracker.GetAppName(),
|
|
Moderated: accessEvaluator.IsModerated(),
|
|
DatabaseName: tracker.GetDatabaseName(),
|
|
Owner: tracker.GetHostUser(),
|
|
Command: strings.Join(tracker.GetCommand(), " "),
|
|
}
|
|
}
|
|
|
|
// clusterActiveAndPendingSessionsGet gets the list of active and pending sessions for a site.
|
|
//
|
|
// GET /v1/webapi/sites/:site/sessions
|
|
func (h *Handler) clusterActiveAndPendingSessionsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
clt, err := sctx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
trackers, err := clt.GetActiveSessionTrackers(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
userRoles, err := clt.GetCurrentUserRoles(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
accessContext := auth.SessionAccessContext{
|
|
Username: sctx.GetUser(),
|
|
Roles: userRoles,
|
|
}
|
|
|
|
sessions := make([]siteSessionsGetResponseSession, 0, len(trackers))
|
|
for _, tracker := range trackers {
|
|
if tracker.GetState() != types.SessionState_SessionStateTerminated {
|
|
session := trackerToLegacySession(tracker, p.ByName("site"))
|
|
// Get the participant modes available to the user from their roles.
|
|
accessEvaluator := auth.NewSessionAccessEvaluator(tracker.GetHostPolicySets(), types.SSHSessionKind, tracker.GetHostUser())
|
|
participantModes := accessEvaluator.CanJoin(accessContext)
|
|
|
|
sessions = append(sessions, siteSessionsGetResponseSession{Session: session, ParticipantModes: participantModes})
|
|
}
|
|
}
|
|
|
|
return siteSessionsGetResponse{Sessions: sessions}, nil
|
|
}
|
|
|
|
const maxStreamBytes = 5 * 1024 * 1024
|
|
|
|
func toFieldsSlice(rawEvents []apievents.AuditEvent) ([]events.EventFields, error) {
|
|
el := make([]events.EventFields, 0, len(rawEvents))
|
|
for _, event := range rawEvents {
|
|
els, err := events.ToEventFields(event)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
el = append(el, els)
|
|
}
|
|
|
|
return el, nil
|
|
}
|
|
|
|
// clusterSearchEvents returns all audit log events matching the provided criteria
|
|
//
|
|
// GET /v1/webapi/sites/:site/events/search
|
|
//
|
|
// Query parameters:
|
|
//
|
|
// "from" : date range from, encoded as RFC3339
|
|
// "to" : date range to, encoded as RFC3339
|
|
// "limit" : optional maximum number of events to return on each fetch
|
|
// "startKey": resume events search from the last event received,
|
|
// empty string means start search from beginning
|
|
// "include" : optional comma-separated list of event names to return e.g.
|
|
// include=session.start,session.end, all are returned if empty
|
|
// "order": optional ordering of events. Can be either "asc" or "desc"
|
|
// for ascending and descending respectively.
|
|
// If no order is provided it defaults to descending.
|
|
func (h *Handler) clusterSearchEvents(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
values := r.URL.Query()
|
|
|
|
var eventTypes []string
|
|
if include := values.Get("include"); include != "" {
|
|
eventTypes = strings.Split(include, ",")
|
|
}
|
|
|
|
searchEvents := func(clt authclient.ClientI, from, to time.Time, limit int, order types.EventOrder, startKey string) ([]apievents.AuditEvent, string, error) {
|
|
return clt.SearchEvents(r.Context(), events.SearchEventsRequest{
|
|
From: from,
|
|
To: to,
|
|
EventTypes: eventTypes,
|
|
Limit: limit,
|
|
Order: order,
|
|
StartKey: startKey,
|
|
})
|
|
}
|
|
return clusterEventsList(r.Context(), sctx, site, r.URL.Query(), searchEvents)
|
|
}
|
|
|
|
// clusterSearchSessionEvents returns session events matching the criteria.
|
|
//
|
|
// GET /v1/webapi/sites/:site/sessions/search
|
|
//
|
|
// Query parameters:
|
|
//
|
|
// "from" : date range from, encoded as RFC3339
|
|
// "to" : date range to, encoded as RFC3339
|
|
// "limit" : optional maximum number of events to return on each fetch
|
|
// "startKey": resume events search from the last event received,
|
|
// empty string means start search from beginning
|
|
// "order": optional ordering of events. Can be either "asc" or "desc"
|
|
// for ascending and descending respectively.
|
|
// If no order is provided it defaults to descending.
|
|
func (h *Handler) clusterSearchSessionEvents(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
searchSessionEvents := func(clt authclient.ClientI, from, to time.Time, limit int, order types.EventOrder, startKey string) ([]apievents.AuditEvent, string, error) {
|
|
return clt.SearchSessionEvents(r.Context(), events.SearchSessionEventsRequest{
|
|
From: from,
|
|
To: to,
|
|
Limit: limit,
|
|
Order: order,
|
|
StartKey: startKey,
|
|
})
|
|
}
|
|
return clusterEventsList(r.Context(), sctx, site, r.URL.Query(), searchSessionEvents)
|
|
}
|
|
|
|
// clusterEventsList returns a list of audit events obtained using the provided
|
|
// searchEvents method.
|
|
func clusterEventsList(ctx context.Context, sctx *SessionContext, site reversetunnelclient.RemoteSite, values url.Values, searchEvents func(clt authclient.ClientI, from, to time.Time, limit int, order types.EventOrder, startKey string) ([]apievents.AuditEvent, string, error)) (interface{}, error) {
|
|
from, err := queryTime(values, "from", time.Now().UTC().AddDate(0, -1, 0))
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
to, err := queryTime(values, "to", time.Now().UTC())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
limit, err := QueryLimit(values, "limit", defaults.EventsIterationLimit)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
order, err := queryOrder(values, "order", types.EventOrderDescending)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
startKey := values.Get("startKey")
|
|
|
|
clt, err := sctx.GetUserClient(ctx, site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
rawEvents, lastKey, err := searchEvents(clt, from, to, limit, order, startKey)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
el, err := toFieldsSlice(rawEvents)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return eventsListGetResponse{Events: el, StartKey: lastKey}, nil
|
|
}
|
|
|
|
// queryTime parses the query string parameter with the specified name as a
|
|
// RFC3339 time and returns it.
|
|
//
|
|
// If there's no such parameter, specified default value is returned.
|
|
func queryTime(query url.Values, name string, def time.Time) (time.Time, error) {
|
|
str := query.Get(name)
|
|
if str == "" {
|
|
return def, nil
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339, str)
|
|
if err != nil {
|
|
return time.Time{}, trace.BadParameter("failed to parse %v as RFC3339 time: %v", name, str)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
// QueryLimit returns the limit parameter with the specified name from the
|
|
// query string.
|
|
//
|
|
// If there's no such parameter, specified default limit is returned.
|
|
func QueryLimit(query url.Values, name string, def int) (int, error) {
|
|
str := query.Get(name)
|
|
if str == "" {
|
|
return def, nil
|
|
}
|
|
limit, err := strconv.Atoi(str)
|
|
if err != nil {
|
|
return 0, trace.BadParameter("failed to parse %v as limit: %v", name, str)
|
|
}
|
|
return limit, nil
|
|
}
|
|
|
|
// queryLimitAsInt32 returns the limit parameter with the specified name from the
|
|
// query string. Similar to function 'queryLimit' except it returns as type int32.
|
|
//
|
|
// If there's no such parameter, specified default limit is returned.
|
|
func QueryLimitAsInt32(query url.Values, name string, def int32) (int32, error) {
|
|
str := query.Get(name)
|
|
if str == "" {
|
|
return def, nil
|
|
}
|
|
limit, err := strconv.ParseInt(str, 10, 32)
|
|
if err != nil {
|
|
return 0, trace.BadParameter("failed to parse %v as limit: %v", name, str)
|
|
}
|
|
return int32(limit), nil
|
|
}
|
|
|
|
// queryOrder returns the order parameter with the specified name from the
|
|
// query string or a default if the parameter is not provided.
|
|
func queryOrder(query url.Values, name string, def types.EventOrder) (types.EventOrder, error) {
|
|
value := query.Get(name)
|
|
switch value {
|
|
case "desc":
|
|
return types.EventOrderDescending, nil
|
|
case "asc":
|
|
return types.EventOrderAscending, nil
|
|
case "":
|
|
return def, nil
|
|
default:
|
|
return types.EventOrderAscending, trace.BadParameter("parameter %v is not a valid ordering", value)
|
|
}
|
|
}
|
|
|
|
// siteSessionStreamGet returns a byte array from a session's stream
|
|
//
|
|
// GET /v1/webapi/sites/:site/namespaces/:namespace/sessions/:sid/stream?query
|
|
//
|
|
// Query parameters:
|
|
//
|
|
// "offset" : bytes from the beginning
|
|
// "bytes" : number of bytes to read (it won't return more than 512Kb)
|
|
//
|
|
// Unlike other request handlers, this one does not return JSON.
|
|
// It returns the binary stream unencoded, directly in the respose body,
|
|
// with Content-Type of application/octet-stream, gzipped with up to 95%
|
|
// compression ratio.
|
|
func (h *Handler) siteSessionStreamGet(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
|
httplib.SetNoCacheHeaders(w.Header())
|
|
|
|
onError := func(err error) {
|
|
h.log.WithError(err).Debug("Unable to retrieve session chunk.")
|
|
http.Error(w, err.Error(), trace.ErrorToCode(err))
|
|
}
|
|
|
|
// authenticate first
|
|
sctx, site, err := h.authenticateRequestWithCluster(w, r, p)
|
|
if err != nil {
|
|
onError(trace.Wrap(err))
|
|
return
|
|
}
|
|
|
|
// get the session
|
|
sid, err := session.ParseID(p.ByName("sid"))
|
|
if err != nil {
|
|
onError(trace.Wrap(err))
|
|
return
|
|
}
|
|
clt, err := sctx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
onError(trace.Wrap(err))
|
|
return
|
|
}
|
|
|
|
// look at 'offset' parameter
|
|
// (skip error check and treat an invalid offset as offset 0)
|
|
query := r.URL.Query()
|
|
offset, _ := strconv.Atoi(query.Get("offset"))
|
|
|
|
max, err := strconv.Atoi(query.Get("bytes"))
|
|
if err != nil || max <= 0 {
|
|
max = maxStreamBytes
|
|
}
|
|
if max > maxStreamBytes {
|
|
max = maxStreamBytes
|
|
}
|
|
|
|
// call the site API to get the chunk:
|
|
bytes, err := clt.GetSessionChunk(apidefaults.Namespace, *sid, offset, max)
|
|
if err != nil {
|
|
onError(trace.Wrap(err))
|
|
return
|
|
}
|
|
// see if we can gzip it:
|
|
var writer io.Writer = w
|
|
for _, acceptedEnc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
|
|
if strings.TrimSpace(acceptedEnc) == "gzip" {
|
|
gzipper := gzip.NewWriter(w)
|
|
writer = gzipper
|
|
defer gzipper.Close()
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, err = writer.Write(bytes)
|
|
if err != nil {
|
|
onError(trace.Wrap(err))
|
|
return
|
|
}
|
|
}
|
|
|
|
type eventsListGetResponse struct {
|
|
// Events is list of events retrieved.
|
|
Events []events.EventFields `json:"events"`
|
|
// StartKey is the position to resume search events.
|
|
StartKey string `json:"startKey"`
|
|
}
|
|
|
|
// siteSessionEventsGet gets the site session by id
|
|
//
|
|
// GET /v1/webapi/sites/:site/namespaces/:namespace/sessions/:sid/events?after=N
|
|
//
|
|
// Query:
|
|
//
|
|
// "after" : cursor value of an event to return "newer than" events
|
|
// good for repeated polling
|
|
//
|
|
// Response body (each event is an arbitrary JSON structure)
|
|
//
|
|
// {"events": [{...}, {...}, ...}
|
|
func (h *Handler) siteSessionEventsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
|
|
sessionID, err := session.ParseID(p.ByName("sid"))
|
|
if err != nil {
|
|
return nil, trace.BadParameter("invalid session ID %q", p.ByName("sid"))
|
|
}
|
|
|
|
clt, err := sctx.GetUserClient(r.Context(), site)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
afterN, err := strconv.Atoi(r.URL.Query().Get("after"))
|
|
if err != nil {
|
|
afterN = 0
|
|
}
|
|
|
|
e, err := clt.GetSessionEvents(apidefaults.Namespace, *sessionID, afterN)
|
|
if err != nil {
|
|
h.log.WithError(err).Debugf("Unable to find events for session %v.", sessionID)
|
|
if trace.IsNotFound(err) {
|
|
return nil, trace.NotFound("unable to find events for session %q", sessionID)
|
|
}
|
|
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return eventsListGetResponse{Events: e}, nil
|
|
}
|
|
|
|
// hostCredentials sends a registration token and metadata to the Auth Server
|
|
// and gets back SSH and TLS certificates.
|
|
func (h *Handler) hostCredentials(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
var req types.RegisterUsingTokenRequest
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
authClient := h.cfg.ProxyClient
|
|
remoteAddr, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
req.RemoteAddr = remoteAddr
|
|
certs, err := authClient.RegisterUsingToken(r.Context(), &req)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return certs, nil
|
|
}
|
|
|
|
// createSSHCert is a web call that generates new SSH certificate based
|
|
// on user's name, password, 2nd factor token and public key user wishes to sign
|
|
//
|
|
// POST /v1/webapi/ssh/certs
|
|
//
|
|
// { "user": "bob", "password": "pass", "otp_token": "tok", "pub_key": "key to sign", "ttl": 1000000000 }
|
|
//
|
|
// # Success response
|
|
//
|
|
// { "cert": "base64 encoded signed cert", "host_signers": [{"domain_name": "example.com", "checking_keys": ["base64 encoded public signing key"]}] }
|
|
func (h *Handler) createSSHCert(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
var req client.CreateSSHCertReq
|
|
if err := httplib.ReadJSON(r, &req); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
authClient := h.cfg.ProxyClient
|
|
|
|
cap, err := authClient.GetAuthPreference(r.Context())
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
authSSHUserReq := authclient.AuthenticateSSHRequest{
|
|
AuthenticateUserRequest: authclient.AuthenticateUserRequest{
|
|
Username: req.User,
|
|
PublicKey: req.PubKey,
|
|
ClientMetadata: clientMetaFromReq(r),
|
|
},
|
|
CompatibilityMode: req.Compatibility,
|
|
TTL: req.TTL,
|
|
RouteToCluster: req.RouteToCluster,
|
|
KubernetesCluster: req.KubernetesCluster,
|
|
AttestationStatement: req.AttestationStatement,
|
|
}
|
|
|
|
if req.HeadlessAuthenticationID != "" {
|
|
// We need to use the default callback timeout rather than the standard client timeout.
|
|
// However, authClient is shared across all Proxy->Auth requests, so we need to create
|
|
// a new client to avoid applying the callback timeout to other concurrent requests. To
|
|
// this end, we create a clone of the HTTP Client with the desired timeout instead.
|
|
httpClient, err := authClient.CloneHTTPClient(
|
|
authclient.ClientParamTimeout(defaults.HeadlessLoginTimeout),
|
|
authclient.ClientParamResponseHeaderTimeout(defaults.HeadlessLoginTimeout),
|
|
)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// HTTP server has shorter WriteTimeout than is needed, so we override WriteDeadline of the connection.
|
|
if conn, err := authz.ConnFromContext(r.Context()); err == nil {
|
|
if err := conn.SetWriteDeadline(h.clock.Now().Add(defaults.HeadlessLoginTimeout)); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
authSSHUserReq.AuthenticateUserRequest.HeadlessAuthenticationID = req.HeadlessAuthenticationID
|
|
loginResp, err := httpClient.AuthenticateSSHUser(r.Context(), authSSHUserReq)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return loginResp, nil
|
|
}
|
|
|
|
switch cap.GetSecondFactor() {
|
|
case constants.SecondFactorOff:
|
|
authSSHUserReq.AuthenticateUserRequest.Pass = &authclient.PassCreds{
|
|
Password: []byte(req.Password),
|
|
}
|
|
case constants.SecondFactorOTP, constants.SecondFactorOn, constants.SecondFactorOptional:
|
|
authSSHUserReq.AuthenticateUserRequest.OTP = &authclient.OTPCreds{
|
|
Password: []byte(req.Password),
|
|
Token: req.OTPToken,
|
|
}
|
|
default:
|
|
return nil, trace.AccessDenied("unsupported second factor type: %q", cap.GetSecondFactor())
|
|
}
|
|
|
|
loginResp, err := authClient.AuthenticateSSHUser(r.Context(), authSSHUserReq)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return loginResp, nil
|
|
}
|
|
|
|
// validateTrustedCluster validates the token for a trusted cluster and returns it's own host and user certificate authority.
|
|
//
|
|
// POST /webapi/trustedclusters/validate
|
|
//
|
|
// * Request body:
|
|
//
|
|
// {
|
|
// "token": "foo",
|
|
// "certificate_authorities": ["AQ==", "Ag=="]
|
|
// }
|
|
//
|
|
// * Response:
|
|
//
|
|
// {
|
|
// "certificate_authorities": ["AQ==", "Ag=="]
|
|
// }
|
|
func (h *Handler) validateTrustedCluster(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
var validateRequestRaw authclient.ValidateTrustedClusterRequestRaw
|
|
if err := httplib.ReadJSON(r, &validateRequestRaw); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
validateRequest, err := validateRequestRaw.ToNative()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
validateResponse, err := h.auth.ValidateTrustedCluster(r.Context(), validateRequest)
|
|
if err != nil {
|
|
h.log.WithError(err).Error("Failed validating trusted cluster")
|
|
if trace.IsAccessDenied(err) {
|
|
return nil, trace.AccessDenied("access denied: the cluster token has been rejected")
|
|
}
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
validateResponseRaw, err := validateResponse.ToRaw()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return validateResponseRaw, nil
|
|
}
|
|
|
|
func (h *Handler) String() string {
|
|
return "multi site"
|
|
}
|
|
|
|
// currentSiteShortcut is a special shortcut that will return the first
|
|
// available site, is helpful when UI works in single site mode to reduce
|
|
// the amount of requests
|
|
const currentSiteShortcut = "-current-"
|
|
|
|
// ContextHandler is a handler called with the auth context, what means it is authenticated and ready to work
|
|
type ContextHandler func(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext) (interface{}, error)
|
|
|
|
// ClusterHandler is a authenticated handler that is called for some existing remote cluster
|
|
type ClusterHandler func(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error)
|
|
|
|
// ClusterWebsocketHandler is a authenticated websocket handler that is called for some existing remote cluster
|
|
type ClusterWebsocketHandler func(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite, ws *websocket.Conn) (interface{}, error)
|
|
|
|
// WithClusterAuth wraps a ClusterHandler to ensure that a request is authenticated to this proxy
|
|
// (the same as WithAuth), as well as to grab the remoteSite (which can represent this local cluster
|
|
// or a remote trusted cluster) as specified by the ":site" url parameter.
|
|
func (h *Handler) WithClusterAuth(fn ClusterHandler) httprouter.Handle {
|
|
return httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
sctx, site, err := h.authenticateRequestWithCluster(w, r, p)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return fn(w, r, p, sctx, site)
|
|
})
|
|
}
|
|
|
|
func (h *Handler) writeErrToWebSocket(ws *websocket.Conn, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
errEnvelope := terminal.Envelope{
|
|
Type: defaults.WebsocketError,
|
|
Payload: trace.UserMessage(err),
|
|
}
|
|
env, err := errEnvelope.Marshal()
|
|
if err != nil {
|
|
h.log.WithError(err).Error("error marshaling proto")
|
|
return
|
|
}
|
|
if err := ws.WriteMessage(websocket.BinaryMessage, env); err != nil {
|
|
h.log.WithError(err).Error("error writing proto")
|
|
return
|
|
}
|
|
}
|
|
|
|
// authnWsUpgrader is an upgrader that allows any origin to connect to the websocket.
|
|
// This makes our lives easier in our automated tests. While ordinarily this would be
|
|
// used to enforce the same-origin policy, we don't need to worry about that for authenticated
|
|
// websockets, which also require a valid bearer token sent over the websocket after upgrade.
|
|
// Therefore even if an attacker were to connect to the websocket and trick the browser into
|
|
// sending the session cookie, they would still fail to send the bearer token needed to authenticate.
|
|
var authnWsUpgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
// WithClusterAuthWebSocket wraps a ClusterWebsocketHandler to ensure that a request is authenticated
|
|
// to this proxy via websocket if websocketAuth is true, or via query parameter if false (the same as WithAuth), as
|
|
// well as to grab the remoteSite (which can represent this local cluster or a remote trusted cluster)
|
|
// as specified by the ":site" url parameter.
|
|
//
|
|
// TODO(lxea): remove the 'websocketAuth' bool once the deprecated websocket handlers are removed
|
|
func (h *Handler) WithClusterAuthWebSocket(websocketAuth bool, fn ClusterWebsocketHandler) httprouter.Handle {
|
|
return httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (any, error) {
|
|
var sctx *SessionContext
|
|
var ws *websocket.Conn
|
|
var site reversetunnelclient.RemoteSite
|
|
var err error
|
|
|
|
if websocketAuth {
|
|
sctx, ws, site, err = h.authenticateWSRequestWithCluster(w, r, p)
|
|
} else {
|
|
sctx, ws, site, err = h.authenticateWSRequestWithClusterDeprecated(w, r, p)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
// WS protocol requires the server send a close message
|
|
// which should be done by downstream users
|
|
defer ws.Close()
|
|
if _, err := fn(w, r, p, sctx, site, ws); err != nil {
|
|
h.writeErrToWebSocket(ws, err)
|
|
}
|
|
return nil, nil
|
|
})
|
|
}
|
|
|
|
// authenticateWSRequestWithCluster ensures that a request is
|
|
// authenticated to this proxy via websocket, returning the
|
|
// *SessionContext (same as AuthenticateRequest), and also grabs the
|
|
// remoteSite (which can represent this local cluster or a remote
|
|
// trusted cluster) as specified by the ":site" url parameter.
|
|
func (h *Handler) authenticateWSRequestWithCluster(w http.ResponseWriter, r *http.Request, p httprouter.Params) (*SessionContext, *websocket.Conn, reversetunnelclient.RemoteSite, error) {
|
|
sctx, ws, err := h.AuthenticateRequestWS(w, r)
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
site, err := h.getSiteByParams(sctx, p)
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
return sctx, ws, site, nil
|
|
}
|
|
|
|
// TODO(lxea): remove once the deprecated websocket handlers are removed
|
|
func (h *Handler) authenticateWSRequestWithClusterDeprecated(w http.ResponseWriter, r *http.Request, p httprouter.Params) (*SessionContext, *websocket.Conn, reversetunnelclient.RemoteSite, error) {
|
|
sctx, site, err := h.authenticateRequestWithCluster(w, r, p)
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
ws, err := authnWsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return nil, nil, nil, trace.Wrap(err)
|
|
}
|
|
return sctx, ws, site, nil
|
|
}
|
|
|
|
// authenticateRequestWithCluster ensures that a request is authenticated
|
|
// to this proxy, returning the *SessionContext (same as AuthenticateRequest),
|
|
// and also grabs the remoteSite (which can represent this local cluster or a
|
|
// remote trusted cluster) as specified by the ":site" url parameter.
|
|
func (h *Handler) authenticateRequestWithCluster(w http.ResponseWriter, r *http.Request, p httprouter.Params) (*SessionContext, reversetunnelclient.RemoteSite, error) {
|
|
sctx, err := h.AuthenticateRequest(w, r, true)
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
site, err := h.getSiteByParams(sctx, p)
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
return sctx, site, nil
|
|
}
|
|
|
|
// getSiteByParams gets the remoteSite (which can represent this local cluster or a
|
|
// remote trusted cluster) as specified by the ":site" url parameter.
|
|
func (h *Handler) getSiteByParams(sctx *SessionContext, p httprouter.Params) (reversetunnelclient.RemoteSite, error) {
|
|
clusterName := p.ByName("site")
|
|
if clusterName == currentSiteShortcut {
|
|
res, err := h.cfg.ProxyClient.GetClusterName()
|
|
if err != nil {
|
|
h.log.WithError(err).Warn("Failed to query cluster name.")
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
clusterName = res.GetClusterName()
|
|
}
|
|
|
|
site, err := h.getSiteByClusterName(sctx, clusterName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return site, nil
|
|
}
|
|
|
|
func (h *Handler) getSiteByClusterName(ctx *SessionContext, clusterName string) (reversetunnelclient.RemoteSite, error) {
|
|
proxy, err := h.ProxyWithRoles(ctx)
|
|
if err != nil {
|
|
h.log.WithError(err).Warn("Failed to get proxy with roles.")
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
site, err := proxy.GetSite(clusterName)
|
|
if err != nil {
|
|
h.log.WithError(err).WithField("cluster-name", clusterName).Warn("Failed to query site.")
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return site, nil
|
|
}
|
|
|
|
// ClusterClientProvider is an interface for a type which can provide
|
|
// authenticated clients to remote clusters.
|
|
type ClusterClientProvider interface {
|
|
// UserClientForCluster returns a client to the local or remote cluster
|
|
// identified by clusterName and is authenticated with the identity of the
|
|
// user.
|
|
UserClientForCluster(ctx context.Context, clusterName string) (authclient.ClientI, error)
|
|
}
|
|
|
|
type clusterClientProvider struct {
|
|
h *Handler
|
|
ctx *SessionContext
|
|
}
|
|
|
|
// UserClientForCluster returns a client to the local or remote cluster
|
|
// identified by clusterName and is authenticated with the identity of the user.
|
|
func (r clusterClientProvider) UserClientForCluster(ctx context.Context, clusterName string) (authclient.ClientI, error) {
|
|
site, err := r.h.getSiteByClusterName(r.ctx, clusterName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
clt, err := r.ctx.GetUserClient(ctx, site)
|
|
return clt, trace.Wrap(err)
|
|
}
|
|
|
|
// ClusterClientHandler is an authenticated handler which can get a client for
|
|
// any remote cluster.
|
|
type ClusterClientHandler func(http.ResponseWriter, *http.Request, httprouter.Params, *SessionContext, ClusterClientProvider) (interface{}, error)
|
|
|
|
// WithClusterClientProvider wraps a ClusterClientHandler to ensure that a
|
|
// request is authenticated to this proxy (the same as WithAuth), and passes a
|
|
// ClusterClientProvider so that the handler can access remote clusters. Use
|
|
// this instead of WithClusterAuth when the remote cluster cannot be encoded in
|
|
// the path or multiple clusters may need to be accessed from a single handler.
|
|
func (h *Handler) WithClusterClientProvider(fn ClusterClientHandler) httprouter.Handle {
|
|
return httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
sctx, err := h.AuthenticateRequest(w, r, true)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
g := clusterClientProvider{
|
|
h: h,
|
|
ctx: sctx,
|
|
}
|
|
return fn(w, r, p, sctx, g)
|
|
})
|
|
}
|
|
|
|
// ProvisionTokenHandler is a authenticated handler that is called for some existing Token
|
|
type ProvisionTokenHandler func(w http.ResponseWriter, r *http.Request, p httprouter.Params, site reversetunnelclient.RemoteSite, token types.ProvisionToken) (interface{}, error)
|
|
|
|
// WithProvisionTokenAuth ensures that request is authenticated with a provision token.
|
|
// Provision tokens, when used like this are invalidated as soon as used.
|
|
// Doesn't matter if the underlying response was a success or an error.
|
|
func (h *Handler) WithProvisionTokenAuth(fn ProvisionTokenHandler) httprouter.Handle {
|
|
return httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
ctx := r.Context()
|
|
|
|
creds, err := roundtrip.ParseAuthHeaders(r)
|
|
if err != nil {
|
|
return nil, trace.AccessDenied("need auth")
|
|
}
|
|
|
|
token, err := consumeTokenForAPICall(ctx, h.GetProxyClient(), creds.Password)
|
|
if err != nil {
|
|
return nil, trace.AccessDenied("need auth")
|
|
}
|
|
|
|
site, err := h.cfg.Proxy.GetSite(h.auth.clusterName)
|
|
if err != nil {
|
|
h.log.WithError(err).WithField("cluster-name", h.auth.clusterName).Warn("Failed to query cluster.")
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return fn(w, r, p, site, token)
|
|
})
|
|
}
|
|
|
|
// consumeTokenForAPICall will fetch a token, check if the requireRole is present and then delete the token
|
|
// If any of those calls returns an error, this method also returns an error
|
|
//
|
|
// If multiple clients reach here at the same time, only one of them will be able to actually make the request.
|
|
// This is possible because the latest call - DeleteToken - returns an error if the resource doesn't exist
|
|
// This is currently true for all the backends as explained here
|
|
// https://github.com/gravitational/teleport/commit/24fcadc375d8359e80790b3ebeaa36bd8dd2822f
|
|
func consumeTokenForAPICall(ctx context.Context, proxyClient authclient.ClientI, tokenName string) (types.ProvisionToken, error) {
|
|
token, err := proxyClient.GetToken(ctx, tokenName)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
if token.GetJoinMethod() != types.JoinMethodToken {
|
|
return nil, trace.BadParameter("unexpected join method %q for token %q", token.GetJoinMethod(), token.GetSafeName())
|
|
}
|
|
|
|
if !checkTokenTTL(token) {
|
|
return nil, trace.BadParameter("expired token %q", token.GetSafeName())
|
|
}
|
|
|
|
if err := proxyClient.DeleteToken(ctx, token.GetName()); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// checkTokenTTL returns true if the token is still valid.
|
|
// This is similar to checkTokenTTL in auth.Server, but does not delete expired tokens.
|
|
func checkTokenTTL(tok types.ProvisionToken) bool {
|
|
// Always accept tokens without an expiry configured.
|
|
if tok.Expiry().IsZero() {
|
|
return true
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
|
|
return tok.Expiry().After(now)
|
|
}
|
|
|
|
type redirectHandlerFunc func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (redirectURL string)
|
|
|
|
func isValidRedirectURL(redirectURL string) bool {
|
|
u, err := url.ParseRequestURI(redirectURL)
|
|
return err == nil && (!u.IsAbs() || (u.Scheme == "http" || u.Scheme == "https"))
|
|
}
|
|
|
|
// WithRedirect is a handler that redirects to the path specified in the returned value.
|
|
func (h *Handler) WithRedirect(fn redirectHandlerFunc) httprouter.Handle {
|
|
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
|
app.SetRedirectPageHeaders(w.Header(), "")
|
|
|
|
redirectURL := fn(w, r, p)
|
|
if !isValidRedirectURL(redirectURL) {
|
|
redirectURL = client.LoginFailedRedirectURL
|
|
}
|
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
|
}
|
|
}
|
|
|
|
// WithMetaRedirect is a handler that redirects to the path specified
|
|
// using HTML rather than HTTP. This is needed for redirects that can
|
|
// have a header size larger than 8kb, which some middlewares will drop.
|
|
// See https://github.com/gravitational/teleport/issues/7467.
|
|
func (h *Handler) WithMetaRedirect(fn redirectHandlerFunc) httprouter.Handle {
|
|
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
|
redirectURL := fn(w, r, p)
|
|
if !isValidRedirectURL(redirectURL) {
|
|
redirectURL = client.LoginFailedRedirectURL
|
|
}
|
|
err := app.MetaRedirect(w, redirectURL)
|
|
if err != nil {
|
|
h.log.WithError(err).Warn("Failed to issue a redirect.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithAuth ensures that a request is authenticated.
|
|
func (h *Handler) WithAuth(fn ContextHandler) httprouter.Handle {
|
|
return httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
sctx, err := h.AuthenticateRequest(w, r, true)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return fn(w, r, p, sctx)
|
|
})
|
|
}
|
|
|
|
// WithAuthCookieAndCSRF ensures that a request is authenticated
|
|
// for plain old non-AJAX requests (does not check the Bearer header).
|
|
// It enforces CSRF checks (except for "safe" methods).
|
|
func (h *Handler) WithAuthCookieAndCSRF(fn ContextHandler) httprouter.Handle {
|
|
f := func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
sctx, err := h.AuthenticateRequest(w, r, false)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return fn(w, r, p, sctx)
|
|
}
|
|
|
|
return httplib.WithCSRFProtection(f)
|
|
}
|
|
|
|
// WithUnauthenticatedLimiter adds a conditional IP-based rate limiting that will limit only unauthenticated requests.
|
|
// This is a good default to use as both Cluster and User auth are checked here, but `WithLimiter` can be used if
|
|
// you're certain that no authenticated requests will be made.
|
|
func (h *Handler) WithUnauthenticatedLimiter(fn httplib.HandlerFunc) httprouter.Handle {
|
|
return h.unauthenticatedLimiterFunc(fn, h.WithLimiterHandlerFunc)
|
|
}
|
|
|
|
// WithUnauthenticatedHighLimiter adds a conditional IP-based rate limiting that will limit only unauthenticated
|
|
// requests. This is similar to WithUnauthenticatedLimiter, however this one allows a much higher rate limit.
|
|
// This higher rate limit should only be used on endpoints which are only CPU constrained
|
|
// (no file or other resources used).
|
|
func (h *Handler) WithUnauthenticatedHighLimiter(fn httplib.HandlerFunc) httprouter.Handle {
|
|
return h.unauthenticatedLimiterFunc(fn, h.WithHighLimiterHandlerFunc)
|
|
}
|
|
|
|
func (h *Handler) unauthenticatedLimiterFunc(fn httplib.HandlerFunc, rateFunc func(fn httplib.HandlerFunc) httplib.HandlerFunc) httprouter.Handle {
|
|
return httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
if _, _, err := h.authenticateRequestWithCluster(w, r, p); err != nil {
|
|
// retry with user auth
|
|
if _, err = h.AuthenticateRequest(w, r, true /* check token */); err != nil {
|
|
// no auth passed, limit request
|
|
return rateFunc(fn)(w, r, p)
|
|
}
|
|
}
|
|
// auth passed, call directly
|
|
return fn(w, r, p)
|
|
})
|
|
}
|
|
|
|
// WithLimiter adds IP-based rate limiting to fn.
|
|
// Limits are applied to all requests, authenticated or not.
|
|
func (h *Handler) WithLimiter(fn httplib.HandlerFunc) httprouter.Handle {
|
|
return httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
return h.WithLimiterHandlerFunc(fn)(w, r, p)
|
|
})
|
|
}
|
|
|
|
// WithHighLimiter adds high rate IP-based rate limiting to fn.
|
|
// This should only be used on functions which are CPU constrained, and don't use disk or other services.
|
|
// Limits are applied to all requests, authenticated or not.
|
|
func (h *Handler) WithHighLimiter(fn httplib.HandlerFunc) httprouter.Handle {
|
|
return httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
return h.WithHighLimiterHandlerFunc(fn)(w, r, p)
|
|
})
|
|
}
|
|
|
|
// WithLimiterHandlerFunc adds IP-based rate limiting to a HandlerFunc. This
|
|
// should be used when you need to nest this inside another HandlerFunc.
|
|
func (h *Handler) WithLimiterHandlerFunc(fn httplib.HandlerFunc) httplib.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
err := rateLimitRequest(r, h.limiter)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return fn(w, r, p)
|
|
}
|
|
}
|
|
|
|
// WithHighLimiterHandlerFunc adds IP-based rate limiting to a HandlerFunc. This is similar to WithLimiterHandlerFunc
|
|
// but provides a higher rate limit. This should only be used for requests which are only CPU bound (no disk or other
|
|
// resources used).
|
|
func (h *Handler) WithHighLimiterHandlerFunc(fn httplib.HandlerFunc) httplib.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
err := rateLimitRequest(r, h.highLimiter)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return fn(w, r, p)
|
|
}
|
|
}
|
|
|
|
func rateLimitRequest(r *http.Request, limiter *limiter.RateLimiter) error {
|
|
remote, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
err = limiter.RegisterRequest(remote, nil /* customRate */)
|
|
// MaxRateError doesn't play well with errors.Is, hence the type assertion.
|
|
var maxRateError *ratelimit.MaxRateError
|
|
if errors.As(err, &maxRateError) {
|
|
return trace.LimitExceeded(err.Error())
|
|
}
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
func (h *Handler) validateCookie(w http.ResponseWriter, r *http.Request) (*SessionContext, error) {
|
|
const missingCookieMsg = "missing session cookie"
|
|
cookie, err := r.Cookie(websession.CookieName)
|
|
if err != nil || (cookie != nil && cookie.Value == "") {
|
|
return nil, trace.AccessDenied(missingCookieMsg)
|
|
}
|
|
decodedCookie, err := websession.DecodeCookie(cookie.Value)
|
|
if err != nil {
|
|
return nil, trace.AccessDenied("failed to decode cookie")
|
|
}
|
|
sctx, err := h.auth.getOrCreateSession(r.Context(), decodedCookie.User, decodedCookie.SID)
|
|
if err != nil {
|
|
clearSessionCookies((w))
|
|
return nil, trace.AccessDenied("need auth")
|
|
}
|
|
|
|
return sctx, nil
|
|
}
|
|
|
|
// AuthenticateRequest authenticates request using combination of a session cookie
|
|
// and bearer token
|
|
func (h *Handler) AuthenticateRequest(w http.ResponseWriter, r *http.Request, checkBearerToken bool) (*SessionContext, error) {
|
|
sctx, err := h.validateCookie(w, r)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
if checkBearerToken {
|
|
creds, err := roundtrip.ParseAuthHeaders(r)
|
|
if err != nil {
|
|
return nil, trace.AccessDenied("need auth")
|
|
}
|
|
if err := sctx.validateBearerToken(r.Context(), creds.Password); err != nil {
|
|
return nil, trace.AccessDenied("bad bearer token")
|
|
}
|
|
}
|
|
|
|
if err := parseMFAResponseFromRequest(r); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return sctx, nil
|
|
}
|
|
|
|
// parseMFAResponse checks for an MFA response in the request header.
|
|
// if found, the mfa response is added to the request context, where
|
|
// it can be recalled to augment client authentication further down
|
|
// the call stack.
|
|
func parseMFAResponseFromRequest(r *http.Request) error {
|
|
ctx, err := contextWithMFAResponseFromRequestHeader(r.Context(), r.Header)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
// Update the request reference with a cloned request with the update ctx.
|
|
*r = *r.WithContext(ctx)
|
|
return nil
|
|
}
|
|
|
|
// contextWithMFAResponseFromRequestHeader attempts to parse an MFA response
|
|
// from the request header. If found, the MFA response is added to the given
|
|
// context and returned.
|
|
func contextWithMFAResponseFromRequestHeader(ctx context.Context, requestHeader http.Header) (context.Context, error) {
|
|
if mfaResponseJSON := requestHeader.Get("Teleport-MFA-Response"); mfaResponseJSON != "" {
|
|
var resp mfaResponse
|
|
if err := json.Unmarshal([]byte(mfaResponseJSON), &resp); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return mfa.ContextWithMFAResponse(ctx, &proto.MFAAuthenticateResponse{
|
|
Response: &proto.MFAAuthenticateResponse_Webauthn{
|
|
Webauthn: wantypes.CredentialAssertionResponseToProto(resp.WebauthnAssertionResponse),
|
|
},
|
|
}), nil
|
|
}
|
|
|
|
return ctx, nil
|
|
}
|
|
|
|
type wsBearerToken struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
type wsStatus struct {
|
|
Type string `json:"type"`
|
|
Status string `json:"status"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// wsIODeadline is used to set a deadline for receiving a message from
|
|
// an authenticated websocket so unauthenticated sockets dont get left
|
|
// open.
|
|
const wsIODeadline = time.Second * 4
|
|
|
|
// AuthenticateRequest authenticates request using combination of a session cookie
|
|
// and bearer token retrieved from a websocket
|
|
func (h *Handler) AuthenticateRequestWS(w http.ResponseWriter, r *http.Request) (*SessionContext, *websocket.Conn, error) {
|
|
sctx, err := h.validateCookie(w, r)
|
|
if err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
ws, err := authnWsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return nil, nil, trace.ConnectionProblem(err, "Error upgrading to websocket: %v", err)
|
|
}
|
|
if err := ws.SetReadDeadline(time.Now().Add(wsIODeadline)); err != nil {
|
|
return nil, nil, trace.ConnectionProblem(err, "Error setting websocket read deadline: %v", err)
|
|
}
|
|
|
|
var t wsBearerToken
|
|
if err := ws.ReadJSON(&t); err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
if err := sctx.validateBearerToken(r.Context(), t.Token); err != nil {
|
|
writeErr := ws.WriteJSON(wsStatus{
|
|
Type: "create_session_response",
|
|
Status: "error",
|
|
Message: "invalid token",
|
|
})
|
|
if writeErr != nil {
|
|
log.Errorf("Error while writing invalid token error to websocket: %s", writeErr)
|
|
}
|
|
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
if err := ws.WriteJSON(wsStatus{
|
|
Type: "create_session_response",
|
|
Status: "ok",
|
|
}); err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
// unset the deadline as downstream consumers should handle this themselves.
|
|
if err := ws.SetReadDeadline(time.Time{}); err != nil {
|
|
return nil, nil, trace.ConnectionProblem(err, "Error setting websocket read deadline: %v", err)
|
|
}
|
|
|
|
if err := parseMFAResponseFromRequest(r); err != nil {
|
|
return nil, nil, trace.Wrap(err)
|
|
}
|
|
|
|
return sctx, ws, nil
|
|
}
|
|
|
|
// ProxyWithRoles returns a reverse tunnel proxy verifying the permissions
|
|
// of the given user.
|
|
func (h *Handler) ProxyWithRoles(ctx *SessionContext) (reversetunnelclient.Tunnel, error) {
|
|
accessChecker, err := ctx.GetUserAccessChecker()
|
|
if err != nil {
|
|
h.log.WithError(err).Warn("Failed to get client roles.")
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
cn, err := h.cfg.AccessPoint.GetClusterName()
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
return reversetunnelclient.NewTunnelWithRoles(h.cfg.Proxy, cn.GetClusterName(), accessChecker, h.cfg.AccessPoint), nil
|
|
}
|
|
|
|
// ProxyHostPort returns the address of the proxy server using --proxy
|
|
// notation, i.e. "localhost:8030,8023"
|
|
func (h *Handler) ProxyHostPort() string {
|
|
hp := createHostPort(h.cfg.ProxyWebAddr, defaults.HTTPListenPort)
|
|
return fmt.Sprintf("%s,%s", hp, h.sshPort)
|
|
}
|
|
|
|
func (h *Handler) kubeProxyHostPort() string {
|
|
return createHostPort(h.cfg.ProxyKubeAddr, defaults.KubeListenPort)
|
|
}
|
|
|
|
// Address can be set in the config with unspecified host, like
|
|
// 0.0.0.0:3080 or :443.
|
|
//
|
|
// In this case, the dial will succeed (dialing 0.0.0.0 is same a dialing
|
|
// localhost in Go) but the host certificate will not validate since
|
|
// 0.0.0.0 is never a valid principal (auth server explicitly removes it
|
|
// when issuing host certs).
|
|
//
|
|
// As such, replace 0.0.0.0 with localhost in this case: proxy listens on
|
|
// all interfaces and localhost is always included in the valid principal
|
|
// set.
|
|
func createHostPort(netAddr utils.NetAddr, port int) string {
|
|
if netAddr.IsHostUnspecified() {
|
|
return fmt.Sprintf("localhost:%v", netAddr.Port(port))
|
|
}
|
|
return netAddr.String()
|
|
}
|
|
|
|
func message(msg string) interface{} {
|
|
return map[string]interface{}{"message": msg}
|
|
}
|
|
|
|
// OK is a response that indicates request was successful.
|
|
func OK() interface{} {
|
|
return message("ok")
|
|
}
|
|
|
|
// makeTeleportClientConfig creates default teleport client configuration
|
|
// that is used to initiate an SSH terminal session or SCP file transfer
|
|
func makeTeleportClientConfig(ctx context.Context, sctx *SessionContext) (*client.Config, error) {
|
|
agent, cert, err := sctx.GetAgent()
|
|
if err != nil {
|
|
return nil, trace.BadParameter("failed to get user credentials: %v", err)
|
|
}
|
|
|
|
signers, err := agent.Signers()
|
|
if err != nil {
|
|
return nil, trace.BadParameter("failed to get user credentials: %v", err)
|
|
}
|
|
|
|
tlsConfig, err := sctx.ClientTLSConfig(ctx)
|
|
if err != nil {
|
|
return nil, trace.BadParameter("failed to get client TLS config: %v", err)
|
|
}
|
|
|
|
callback, err := apisshutils.NewHostKeyCallback(
|
|
apisshutils.HostKeyCallbackConfig{
|
|
GetHostCheckers: sctx.getCheckers,
|
|
Clock: sctx.cfg.Parent.clock,
|
|
})
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
proxyListenerMode, err := sctx.GetProxyListenerMode(ctx)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
config := &client.Config{
|
|
Username: sctx.GetUser(),
|
|
Agent: agent,
|
|
NonInteractive: true,
|
|
TLS: tlsConfig,
|
|
AuthMethods: []ssh.AuthMethod{ssh.PublicKeys(signers...)},
|
|
ProxySSHPrincipal: cert.ValidPrincipals[0],
|
|
HostKeyCallback: callback,
|
|
TLSRoutingEnabled: proxyListenerMode == types.ProxyListenerMode_Multiplex,
|
|
Tracer: apitracing.DefaultProvider().Tracer("webterminal"),
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// SSORequestParams holds parameters parsed out of a HTTP request initiating an
|
|
// SSO login. See ParseSSORequestParams().
|
|
type SSORequestParams struct {
|
|
// ClientRedirectURL is the URL specified in the query parameter
|
|
// redirect_url, which will be unescaped here.
|
|
ClientRedirectURL string
|
|
// ConnectorID identifies the SSO connector to use to log in, from
|
|
// the connector_id query parameter.
|
|
ConnectorID string
|
|
// CSRFToken is the token in the CSRF cookie header.
|
|
CSRFToken string
|
|
}
|
|
|
|
// ParseSSORequestParams extracts the SSO request parameters from an http.Request,
|
|
// returning them in an SSORequestParams struct. If any fields are not present,
|
|
// an error is returned.
|
|
func ParseSSORequestParams(r *http.Request) (*SSORequestParams, error) {
|
|
// Manually grab the value from query param "redirect_url".
|
|
//
|
|
// The "redirect_url" param can contain its own query params such as in
|
|
// "https://localhost/login?connector_id=github&redirect_url=https://localhost:8080/web/cluster/im-a-cluster-name/nodes?search=tunnel&sort=hostname:asc",
|
|
// which would be incorrectly parsed with the standard method.
|
|
// For example a call to r.URL.Query().Get("redirect_url") in the example above would return
|
|
// "https://localhost:8080/web/cluster/im-a-cluster-name/nodes?search=tunnel",
|
|
// as it would take the "&sort=hostname:asc" to be a separate query param.
|
|
//
|
|
// This logic assumes that anything coming after "redirect_url" is part of
|
|
// the redirect URL.
|
|
splittedRawQuery := strings.Split(r.URL.RawQuery, "&redirect_url=")
|
|
var clientRedirectURL string
|
|
if len(splittedRawQuery) > 1 {
|
|
clientRedirectURL, _ = url.QueryUnescape(splittedRawQuery[1])
|
|
}
|
|
if clientRedirectURL == "" {
|
|
return nil, trace.BadParameter("missing redirect_url query parameter")
|
|
}
|
|
|
|
query := r.URL.Query()
|
|
connectorID := query.Get("connector_id")
|
|
if connectorID == "" {
|
|
return nil, trace.BadParameter("missing connector_id query parameter")
|
|
}
|
|
|
|
csrfToken, err := csrf.ExtractTokenFromCookie(r)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return &SSORequestParams{
|
|
ClientRedirectURL: clientRedirectURL,
|
|
ConnectorID: connectorID,
|
|
CSRFToken: csrfToken,
|
|
}, nil
|
|
}
|
|
|
|
// SSOCallbackResponse holds the parameters for validating and executing an SSO
|
|
// callback URL. See SSOSetWebSessionAndRedirectURL().
|
|
type SSOCallbackResponse struct {
|
|
// CSRFToken is the token provided in the originating SSO login request
|
|
// to be validated against.
|
|
CSRFToken string
|
|
// Username is the authenticated teleport username of the user that has
|
|
// logged in, provided by the SSO provider.
|
|
Username string
|
|
// SessionName is the name of the session generated by auth server if
|
|
// requested in the SSO request.
|
|
SessionName string
|
|
// ClientRedirectURL is the URL to redirect back to on completion of
|
|
// the SSO login process.
|
|
ClientRedirectURL string
|
|
}
|
|
|
|
// SSOSetWebSessionAndRedirectURL validates the CSRF token in the response
|
|
// against that in the request, validates that the callback URL in the response
|
|
// can be parsed, and sets a session cookie with the username and session name
|
|
// from the response. On success, nil is returned. If the validation fails, an
|
|
// error is returned.
|
|
func SSOSetWebSessionAndRedirectURL(w http.ResponseWriter, r *http.Request, response *SSOCallbackResponse, verifyCSRF bool) error {
|
|
if verifyCSRF {
|
|
if err := csrf.VerifyToken(response.CSRFToken, r); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
}
|
|
|
|
if err := websession.SetCookie(w, response.Username, response.SessionName); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
parsedRedirectURL, err := httplib.OriginLocalRedirectURI(response.ClientRedirectURL)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
response.ClientRedirectURL = parsedRedirectURL
|
|
|
|
return nil
|
|
}
|
|
|
|
// authExportPublic returns the CA Certs that can be used to set up a chain of trust which includes the current Teleport Cluster
|
|
//
|
|
// GET /webapi/sites/:site/auth/export?type=<auth type>
|
|
// GET /webapi/auth/export?type=<auth type>
|
|
func (h *Handler) authExportPublic(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
|
err := rateLimitRequest(r, h.limiter)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), trace.ErrorToCode(err))
|
|
return
|
|
}
|
|
authorities, err := client.ExportAuthorities(
|
|
r.Context(),
|
|
h.GetProxyClient(),
|
|
client.ExportAuthoritiesRequest{
|
|
AuthType: r.URL.Query().Get("type"),
|
|
},
|
|
)
|
|
if err != nil {
|
|
h.log.WithError(err).Debug("Failed to generate CA Certs.")
|
|
http.Error(w, err.Error(), trace.ErrorToCode(err))
|
|
return
|
|
}
|
|
|
|
reader := strings.NewReader(authorities)
|
|
|
|
// ServeContent sets the correct headers: Content-Type, Content-Length and Accept-Ranges.
|
|
// It also handles the Range negotiation
|
|
http.ServeContent(w, r, "authorized_hosts.txt", time.Now(), reader)
|
|
}
|
|
|
|
const robots = `User-agent: *
|
|
Disallow: /`
|
|
|
|
func serveRobotsTxt(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(robots))
|
|
return nil, nil
|
|
}
|
|
|
|
func readEtagFromAppHash(fs http.FileSystem) (string, error) {
|
|
hashFile, err := fs.Open("/apphash")
|
|
if err != nil {
|
|
return "", trace.Wrap(err)
|
|
}
|
|
defer hashFile.Close()
|
|
|
|
appHash, err := io.ReadAll(hashFile)
|
|
if err != nil {
|
|
return "", trace.Wrap(err)
|
|
}
|
|
|
|
versionWithHash := fmt.Sprintf("%s-%s", teleport.Version, string(appHash))
|
|
etag := fmt.Sprintf("%q", versionWithHash)
|
|
|
|
return etag, nil
|
|
}
|