Files
teleport/lib/web/apiserver.go
T

5210 lines
195 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 (
"cmp"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"math/rand/v2"
"net"
"net/http"
"net/url"
"os"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
gogoproto "github.com/gogo/protobuf/proto"
"github.com/google/safetext/shsprintf"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/gravitational/roundtrip"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/julienschmidt/httprouter"
"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"
"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"
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
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/entitlements"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/auth/authclient"
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"
dbrepl "github.com/gravitational/teleport/lib/client/db/repl"
"github.com/gravitational/teleport/lib/client/sso"
"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"
"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/player"
"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/services/readonly"
"github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
logutils "github.com/gravitational/teleport/lib/utils/log"
"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."
// 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"
// IncludedResourceModeAll describes that only requestable resources should be returned.
IncludedResourceModeRequestable = "requestable"
// IncludedResourceModeAll describes that all resources, requestable and available, should be returned.
IncludedResourceModeAll = "all"
// DefaultFeatureWatchInterval is the default time in which the feature watcher
// should ping the auth server to check for updated features
DefaultFeatureWatchInterval = time.Minute * 5
// findEndpointCacheTTL is the cache TTL for the find endpoint generic answer.
// This cache is here to protect against accidental or intentional DDoS, the TTL must be low to quickly reflect
// cluster configuration changes.
findEndpointCacheTTL = 10 * time.Second
// cmcCacheTTL is the cache TTL for the clusterMaintenanceConfig resource.
// This cache is here to protect against accidental or intentional DDoS, the TTL must be low to quickly reflect
// cluster configuration changes.
cmcCacheTTL = time.Minute
// DefaultAgentUpdateJitterSeconds is the default jitter agents should wait before updating.
DefaultAgentUpdateJitterSeconds = 60
)
// 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 {
logger *slog.Logger
sync.Mutex
httprouter.Router
cfg Config
auth *sessionCache
clock clockwork.Clock
limiter *limiter.RateLimiter
highLimiter *limiter.RateLimiter
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.
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.GenericWatcher[types.Server, readonly.Server]
// tracer is used to create spans.
tracer oteltrace.Tracer
// findEndpointCache is used to cache the find endpoint answer. As this endpoint is unprotected and has high
// rate-limits, each call must cause minimal work. The cached answer can be modulated after, for example if the
// caller specified its Automatic Updates UUID or group.
findEndpointCache *utils.FnCache
// clusterMaintenanceConfig is used to cache the cluster maintenance config from the AUth Service.
clusterMaintenanceConfigCache *utils.FnCache
}
// HandlerOption is a functional argument - an option that can be passed
// to NewHandler function
type HandlerOption func(h *Handler) error
// 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, mfaCeremony *mfa.Ceremony, 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
// 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
// GetProxyClientCertificate returns the proxy client certificate.
GetProxyClientCertificate func() (*tls.Certificate, 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 an HTTP upgrade call.
//
// It’s also used in scenarios where the Proxy needs to dial to itself (e.g.
// database access via ws), but the handler can directly forward the traffic
// to the ALPN router without initiating a new connection.
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
// NodeWatcher is a services.NodeWatcher used by Assist to lookup nodes from
// the proxy's cache and get nodes in real time.
NodeWatcher *services.GenericWatcher[types.Server, readonly.Server]
// 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
// FeatureWatchInterval is the interval between pings to the auth server
// to fetch new cluster features
FeatureWatchInterval time.Duration
// DatabaseREPLRegistry is used for retrieving database REPL.
DatabaseREPLRegistry dbrepl.REPLRegistry
}
// 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
}
c.FeatureWatchInterval = cmp.Or(c.FeatureWatchInterval, DefaultFeatureWatchInterval)
}
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
func (h *APIHandler) handlePreflight(w http.ResponseWriter, r *http.Request) {
raddr, err := utils.ParseAddr(r.Host)
if err != nil {
return
}
publicAddr := raddr.Host()
servers, err := app.MatchUnshuffled(r.Context(), h.handler.cfg.AccessPoint, app.MatchPublicAddr(publicAddr))
if err != nil {
h.handler.logger.InfoContext(r.Context(), "failed to match application with public addr", "public_addr", publicAddr)
return
}
if len(servers) == 0 {
h.handler.logger.InfoContext(r.Context(), "failed to match application with public addr", "public_addr", publicAddr)
return
}
foundApp := servers[rand.N(len(servers))].GetApp()
corsPolicy := foundApp.GetCORS()
if corsPolicy == nil {
return
}
origin := r.Header.Get("Origin")
// The Access-Control-Allow-Origin can only include one origin or a wildcard. However,
// any request which includes credentials _must_ return an origin and not a wildcard.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#sect2
if slices.Contains(corsPolicy.AllowedOrigins, "*") || slices.Contains(corsPolicy.AllowedOrigins, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
} else {
return
}
if len(corsPolicy.AllowedMethods) > 0 {
w.Header().Set("Access-Control-Allow-Methods", strings.Join(corsPolicy.AllowedMethods, ","))
}
// This is a list of headers that are allowed in the spec. Wildcards are allowed.
// Note: "Authorization" headers must be explicitly listed and cannot be wildcarded
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers#sect2
if len(corsPolicy.AllowedHeaders) > 0 {
w.Header().Set("Access-Control-Allow-Headers", strings.Join(corsPolicy.AllowedHeaders, ","))
}
if len(corsPolicy.ExposedHeaders) > 0 {
w.Header().Set("Access-Control-Expose-Headers", strings.Join(corsPolicy.ExposedHeaders, ","))
}
// The only valid value for this header is "true", so we will only set it if configured to true
if corsPolicy.AllowCredentials {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
// This will allow preflight responses to be cached for the specified duration
if corsPolicy.MaxAge > 0 {
w.Header().Set("Access-Control-Max-Age", fmt.Sprintf("%d", corsPolicy.MaxAge))
}
w.WriteHeader(http.StatusOK)
}
// 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
}
// if the request is for an app, passthrough OPTIONS requests to the app handler
redir, ok := app.HasName(r, h.handler.cfg.ProxyPublicAddrs)
if ok && r.Method == http.MethodOptions {
h.handlePreflight(w, r)
return
}
// Only try to redirect if the handler is serving the full Web API.
if !h.handler.cfg.MinimalReverseTunnelRoutesOnly && 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) {
cfg.SetDefaults()
h := &Handler{
cfg: cfg,
logger: slog.Default().With(teleport.ComponentKey, teleport.ComponentWeb),
clock: clockwork.NewRealClock(),
clusterFeatures: cfg.ClusterFeatures,
healthCheckAppServer: cfg.HealthCheckAppServer,
tracer: cfg.TracerProvider.Tracer(teleport.ComponentWeb),
}
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)
}
}
// We create the cache after applying the options to make sure we use the fake clock if it was passed.
findCache, err := utils.NewFnCache(utils.FnCacheConfig{
TTL: findEndpointCacheTTL,
Clock: h.clock,
Context: cfg.Context,
ReloadOnErr: false,
})
if err != nil {
return nil, trace.Wrap(err, "creating /find cache")
}
h.findEndpointCache = findCache
// We create the cache after applying the options to make sure we use the fake clock if it was passed.
cmcCache, err := utils.NewFnCache(utils.FnCacheConfig{
TTL: cmcCacheTTL,
Clock: h.clock,
Context: cfg.Context,
ReloadOnErr: false,
})
if err != nil {
return nil, trace.Wrap(err, "creating cluster maintenance config cache")
}
h.clusterMaintenanceConfigCache = cmcCache
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,
logger: h.logger,
})
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.logger.WarnContext(h.cfg.Context, "Invalid SSH proxy address, will use default port",
"error", err,
"ssh_proxy_addr", logutils.StringerAttr(&cfg.ProxySSHAddr),
"default_port", 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,
})
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,
})
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.logger.ErrorContext(h.cfg.Context, "Failed to open index file", "error", err)
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))
etagFromAppHash, err := readEtagFromAppHash(cfg.StaticFS)
if err != nil {
h.logger.ErrorContext(h.cfg.Context, "Could not read apphash from embedded webassets. Using version only as ETag for Web UI assets", "error", err)
} else {
etag = etagFromAppHash
}
}
// This endpoint is used both by Web UI and Connect.
h.Handle("GET", "/web/config.js", h.WithUnauthenticatedLimiter(h.getWebConfig))
if cfg.NodeWatcher != nil {
h.nodeWatcher = cfg.NodeWatcher
}
const v1Prefix = "/v1"
notFoundRoutingHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Request is going to the API?
// If no routes were matched, it could be because it's a path with `v1` prefix
// (eg: the Teleport web app will call "most" endpoints with v1 prefixed).
//
// `v1` paths are not defined with `v1` prefix. If the path turns out to be prefixed
// with `v1`, it will be stripped and served again. Historically, that's how it started
// and should be kept that way to prevent breakage.
//
// v2+ prefixes will be expected by both caller and definition and will not be stripped.
if strings.HasPrefix(r.URL.Path, v1Prefix) {
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) > 2 {
// check against known second part of path to ensure we
// aren't allowing paths like /v1/v2/webapi
// part[0] is empty space from leading slash "/"
// part[1] is the prefix "v1"
switch pathParts[2] {
case "webapi", "enterprise", "scripts", ".well-known", "workload-identity", "web":
http.StripPrefix(v1Prefix, h).ServeHTTP(w, r)
return
}
}
httplib.RouteNotFoundResponse(r.Context(), w)
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.logger.WarnContext(r.Context(), "Failed to generate CSRF token", "error", err)
}
// Ignore errors here, as unauthenticated requests for index.html are common - the user might
// not have logged in yet, or their session may have expired.
// The web app will show them the login page in this case.
session, _ := h.authenticateWebSession(w, r)
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.logger.ErrorContext(r.Context(), "Failed to execute index page template", "error", err)
}
} else {
httplib.RouteNotFoundResponse(r.Context(), w)
return
}
})
h.NotFound = notFoundRoutingHandler
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
}
}
go h.startFeatureWatcher()
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 /* validate bearer token */)
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", httplib.MakeHandler(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))
// 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", h.WithLimiter(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", h.WithLimiter(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))
h.POST("/webapi/headless/login", h.WithUnauthenticatedLimiter(h.headlessLogin))
// list available sites
h.GET("/webapi/sites", h.WithAuth(h.getClusters))
// Site specific API
// get site info
h.GET("/webapi/sites/:site/info", h.WithClusterAuth(h.getClusterInfo))
// 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 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
h.GET("/webapi/sites/:site/connect/ws", h.WithClusterAuthWebSocket(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/exec/ws", h.WithClusterAuthWebSocket(h.podConnect)) // connect to a pod with exec (via websocket, with auth over websocket)
h.GET("/webapi/sites/:site/db/exec/ws", h.WithClusterAuthWebSocket(h.dbConnect))
// 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))
h.GET("/webapi/sites/:site/sessionlength/:sid", h.WithClusterAuth(h.sessionLengthHandle))
// 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)
// join token handlers
h.PUT("/webapi/tokens/yaml", h.WithAuth(h.updateTokenYAML))
// used for creating a new token
h.POST("/webapi/tokens", h.WithAuth(h.upsertTokenHandle))
// used for updating a token
h.PUT("/webapi/tokens", h.WithAuth(h.upsertTokenHandle))
// TODO(kimlisa): DELETE IN 19.0 - Replaced by /v2/webapi/token endpoint
// MUST delete with related code found in web/packages/teleport/src/services/joinToken/joinToken.ts(fetchJoinToken)
h.POST("/webapi/token", h.WithAuth(h.createTokenForDiscoveryHandle))
// used for creating tokens used during guided discover flows
// v2 endpoint processes "suggestedLabels" field
h.POST("/v2/webapi/token", h.WithAuth(h.createTokenForDiscoveryHandle))
h.GET("/webapi/tokens", h.WithAuth(h.getTokens))
h.DELETE("/webapi/tokens", h.WithAuth(h.deleteToken))
// install script, the ':token' wildcard is a hack to make the router happy and support
// the token-less route "/scripts/install.sh".
// h.installScriptHandle Will reject any unknown sub-route.
h.GET("/scripts/:token", h.WithHighLimiter(h.installScriptHandle))
// 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))
// Database access handlers.
h.GET("/webapi/sites/:site/databases", h.WithClusterAuth(h.clusterDatabasesGet))
h.POST("/webapi/sites/:site/databases", h.WithClusterAuth(h.handleDatabaseCreateOrOverwrite))
h.PUT("/webapi/sites/:site/databases/:database", h.WithClusterAuth(h.handleDatabasePartialUpdate))
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))
// Database server handlers
h.GET("/webapi/sites/:site/databaseservers", h.WithClusterAuth(h.clusterDatabaseServersList))
// Kube access handlers.
h.GET("/webapi/sites/:site/kubernetes", h.WithClusterAuth(h.clusterKubesGet))
h.GET("/webapi/sites/:site/kubernetes/resources", h.WithClusterAuth(h.clusterKubeResourcesGet))
// 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))
// TODO(Joerger) v19.0.0: currently unused, WebUI can use these in v19 without backwards compatibility concerns.
h.DELETE("/webapi/mfa/devices", h.WithAuth(h.deleteMFADeviceHandle))
h.POST("/webapi/mfa/registerchallenge", h.WithAuth(h.createRegisterChallengeHandle))
h.POST("/webapi/mfa/devices", h.WithAuth(h.addMFADeviceHandle))
// Device Trust.
// Do not enforce bearer token for /webconfirm, it is called from outside the
// Web UI.
h.GET("/webapi/devices/webconfirm", h.WithSession(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.GET("/webapi/roles/:name", h.WithAuth(h.getRole))
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))
// The extra "connector" in the path is to avoid a wildcard conflict with the github handlers used
// during the login flow ("github/login/web" and "github/callback").
h.GET("/webapi/github/connector/:name", h.WithAuth(h.getGithubConnectorHandle))
h.PUT("/webapi/github/:name", h.WithAuth(h.updateGithubConnectorHandle))
h.DELETE("/webapi/github/:name", h.WithAuth(h.deleteGithubConnector))
// Sets the default connector in the auth preference.
h.PUT("/webapi/authconnector/default", h.WithAuth(h.setDefaultConnectorHandle))
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.getAppDetails))
h.GET("/webapi/apps/:fqdnHint/:clusterName/:publicAddr", h.WithAuth(h.getAppDetails))
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?username=<username>&width=<width>&height=<height>
h.GET("/webapi/sites/:site/desktops/:desktopName/connect/ws", h.WithClusterAuthWebSocket(h.desktopConnectHandle))
// GET /webapi/sites/:site/desktopplayback/:sid/ws
h.GET("/webapi/sites/:site/desktopplayback/:sid/ws", h.WithClusterAuthWebSocket(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.GET("/webapi/sites/:site/integrations/:name/stats", h.WithClusterAuth(h.integrationStats))
h.GET("/webapi/sites/:site/integrations/:name/discoveryrules", h.WithClusterAuth(h.integrationDiscoveryRules))
h.GET("/webapi/sites/:site/integrations/:name/ca", h.WithClusterAuth(h.integrationsExportCA))
// TODO(kimlisa): DELETE IN 19.0 - Replaced by /v2 equivalent endpoint
h.DELETE("/webapi/sites/:site/integrations/:name_or_subkind", h.WithClusterAuth(h.integrationsDelete))
h.DELETE("/v2/webapi/sites/:site/integrations/:name_or_subkind", h.WithClusterAuth(h.integrationsDelete))
// GET the Microsoft Teams plugin app.zip file.
h.GET("/webapi/sites/:site/plugins/:plugin/files/msteams_app.zip", h.WithClusterAuth(h.integrationsMsTeamsAppZipGet))
// 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/ping", h.WithClusterAuth(h.awsOIDCPing))
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.POST("/webapi/sites/:site/integrations/aws-oidc/:name/listdeployeddatabaseservices", h.WithClusterAuth(h.awsOIDCListDeployedDatabaseService))
h.GET("/webapi/scripts/integrations/configure/deployservice-iam.sh", h.WithLimiter(h.awsOIDCConfigureDeployServiceIAM))
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/eksclusters", h.WithClusterAuth(h.awsOIDCListEKSClusters))
// TODO(kimlisa): DELETE IN 19.0 - replaced by /v2/webapi/sites/:site/integrations/aws-oidc/:name/enrolleksclusters
// MUST delete with related code found in web/packages/teleport/src/services/integrations/integrations.ts(enrollEksClusters)
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/enrolleksclusters", h.WithClusterAuth(h.awsOIDCEnrollEKSClusters))
// v2 endpoint introduces "extraLabels" field.
h.POST("/v2/webapi/sites/:site/integrations/aws-oidc/:name/enrolleksclusters", h.WithClusterAuth(h.awsOIDCEnrollEKSClusters))
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/securitygroups", h.WithClusterAuth(h.awsOIDCListSecurityGroups))
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/databasevpcs", h.WithClusterAuth(h.awsOIDCListDatabaseVPCs))
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/subnets", h.WithClusterAuth(h.awsOIDCListSubnets))
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/requireddatabasesvpcs", h.WithClusterAuth(h.awsOIDCRequiredDatabasesVPCS))
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))
// TODO(kimlisa): DELETE IN 19.0 - Replaced by /v2 equivalent endpoint
h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/aws-app-access", h.WithClusterAuth(h.awsOIDCCreateAWSAppAccess))
// v2 endpoint introduces "labels" field
// MUST delete with related code found in web/packages/teleport/src/services/integrations/integrations.ts(createAwsAppAccess)
h.POST("/v2/webapi/sites/:site/integrations/aws-oidc/:name/aws-app-access", h.WithClusterAuth(h.awsOIDCCreateAWSAppAccess))
// The Integration DELETE endpoint already sets the expected named param after `/integrations/`
// It must be re-used here, otherwise the router will not start.
// See https://github.com/julienschmidt/httprouter/issues/364
h.DELETE("/webapi/sites/:site/integrations/:name_or_subkind/aws-app-access/:name", h.WithClusterAuth(h.awsOIDCDeleteAWSAppAccess))
h.GET("/webapi/scripts/integrations/configure/ec2-ssm-iam.sh", h.WithLimiter(h.awsOIDCConfigureEC2SSMIAM))
// AWS IAM Roles Anywhere Integration Actions
h.GET("/webapi/scripts/integrations/configure/awsra-trust-anchor.sh", h.WithLimiter(h.awsRolesAnywhereConfigureTrustAnchor))
// SAML IDP integration endpoints
h.GET("/webapi/scripts/integrations/configure/gcp-workforce-saml.sh", h.WithLimiter(h.gcpWorkforceConfigScript))
// Okta integration endpoints.
h.GET(OktaJWKSWellknownURI, h.WithLimiter(h.jwksOkta))
// Azure OIDC integration endpoints
h.GET("/webapi/scripts/integrations/configure/azureoidc.sh", h.WithLimiter(h.azureOIDCConfigure))
// 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))
// SPIFFE Federation Trust Bundle
h.GET("/webapi/spiffe/bundle.json", h.WithLimiter(h.getSPIFFEBundle))
h.GET("/workload-identity/jwt-jwks.json", h.WithLimiter(h.getSPIFFEJWKS))
h.GET("/workload-identity/.well-known/openid-configuration", h.WithLimiter(h.getSPIFFEOIDCDiscoveryDocument))
// 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))
// User Tasks CRUD
// Listing Tasks by Integration: GET /webapi/sites/:site/usertask?integration=<integration-name>
h.GET("/webapi/sites/:site/usertask", h.WithClusterAuth(h.userTaskListByIntegration))
h.GET("/webapi/sites/:site/usertask/:name", h.WithClusterAuth(h.userTaskGet))
h.PUT("/webapi/sites/:site/usertask/:name/state", h.WithClusterAuth(h.userTaskStateUpdate))
// Connection upgrades.
h.GET("/webapi/connectionupgrade", httplib.MakeHandler(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))
// 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.automaticUpgrades109))
// 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 Machine ID instance for a bot by id
h.GET("/webapi/sites/:site/machine-id/bot/:name/bot-instance/:id", h.WithClusterAuth(h.getBotInstance))
// GET Machine ID bot instances (paged)
h.GET("/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstances))
// 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))
// Git servers
h.PUT("/webapi/sites/:site/gitservers", h.WithClusterAuth(h.gitServerCreateOrUpsert))
h.GET("/webapi/sites/:site/gitservers/:name", h.WithClusterAuth(h.gitServerGet))
h.DELETE("/webapi/sites/:site/gitservers/:name", h.WithClusterAuth(h.gitServerDelete))
}
// GetProxyClient returns authenticated auth server client
func (h *Handler) GetProxyClient() authclient.ClientI {
return h.cfg.ProxyClient
}
// GetProxyClientCertificate returns the proxy client certificate.
func (h *Handler) GetProxyClientCertificate() (*tls.Certificate, error) {
if h.cfg.GetProxyClientCertificate == nil {
return nil, trace.BadParameter("GetProxyClientCertificate is not set")
}
tlsCert, err := h.cfg.GetProxyClientCertificate()
if err != nil {
return nil, trace.Wrap(err)
}
return tlsCert, nil
}
// 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 {
RequiresDeviceTrust types.TrustedDeviceRequirement `json:"requiresDeviceTrust,omitempty"`
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{
RequiresDeviceTrust: c.cfg.Session.GetTrustedDeviceRequirement(),
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(r.Context())
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
features := h.GetClusterFeatures()
entitlement := modules.GetProtoEntitlement(&features, entitlements.AccessMonitoring)
// ensure entitlement is set & feature is configured
accessMonitoringEnabled := entitlement.Enabled && features.GetAccessMonitoringConfigured()
userContext, err := ui.NewUserContext(user, accessChecker.Roles(), features, 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,
RequireReason: res.RequireReason,
}
userContext.AllowedSearchAsRoles = accessChecker.GetAllowedSearchAsRoles()
userContext.Cluster, err = ui.GetClusterDetails(r.Context(), site)
if err != nil {
return nil, trace.Wrap(err)
}
pingResp, err := clt.Ping(r.Context())
if err != nil {
return nil, trace.Wrap(err)
}
if pingResp.LicenseExpiry != nil && !pingResp.LicenseExpiry.IsZero() {
userContext.Cluster.LicenseExpiry = pingResp.LicenseExpiry
}
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(ctx context.Context, cap types.AuthPreference, logger *slog.Logger) (webclient.AuthenticationSettings, error) {
as := webclient.AuthenticationSettings{
Type: constants.Local,
SecondFactor: types.LegacySecondFactorFromSecondFactors(cap.GetSecondFactors()),
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
AllowPasswordless: cap.GetAllowPasswordless(),
AllowHeadless: cap.GetAllowHeadless(),
Local: &webclient.LocalSettings{},
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
PIVSlot: cap.GetPIVSlot(),
PIVPINCacheTTL: cap.GetPIVPINCacheTTL(),
DeviceTrust: deviceTrustSettings(cap),
SignatureAlgorithmSuite: cap.GetSignatureAlgorithmSuite(),
}
// 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):
logger.WarnContext(ctx, "Error reading U2F settings", "error", err)
}
// Webauthn settings.
switch webConfig, err := cap.GetWebauthn(); {
case err == nil:
as.Webauthn = &webclient.Webauthn{
RPID: webConfig.RPID,
}
case !trace.IsNotFound(err):
logger.WarnContext(ctx, "Error reading WebAuthn settings", "error", err)
}
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(),
IssuerURL: connector.GetIssuerURL(),
},
// Local fallback / MFA.
SecondFactor: types.LegacySecondFactorFromSecondFactors(cap.GetSecondFactors()),
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
PIVSlot: cap.GetPIVSlot(),
PIVPINCacheTTL: cap.GetPIVPINCacheTTL(),
DeviceTrust: deviceTrustSettings(cap),
SignatureAlgorithmSuite: cap.GetSignatureAlgorithmSuite(),
}
}
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(),
SingleLogoutEnabled: connector.GetSingleLogoutURL() != "",
// Note that we get the connector's primary SSO field, not the MFA SSO field.
// These two values are often unique, but should have the same host prefix
// (e.g. https://dev-813354.oktapreview.com) in reasonable, functional setups.
SSO: connector.GetSSO(),
},
// Local fallback / MFA.
SecondFactor: types.LegacySecondFactorFromSecondFactors(cap.GetSecondFactors()),
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
PIVSlot: cap.GetPIVSlot(),
PIVPINCacheTTL: cap.GetPIVPINCacheTTL(),
DeviceTrust: deviceTrustSettings(cap),
SignatureAlgorithmSuite: cap.GetSignatureAlgorithmSuite(),
}
}
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(),
EndpointURL: connector.GetEndpointURL(),
},
// Local fallback / MFA.
SecondFactor: types.LegacySecondFactorFromSecondFactors(cap.GetSecondFactors()),
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
PIVSlot: cap.GetPIVSlot(),
PIVPINCacheTTL: cap.GetPIVPINCacheTTL(),
DeviceTrust: deviceTrustSettings(cap),
SignatureAlgorithmSuite: cap.GetSignatureAlgorithmSuite(),
}
}
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, logger *slog.Logger) (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(ctx, authPreference, logger)
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.GetSAMLConnectorWithValidationOptions(ctx, authPreference.GetConnectorName(), false, types.SAMLConnectorValidationFollowURLs(false))
if err != nil {
return webclient.AuthenticationSettings{}, trace.Wrap(err)
}
as = samlSettings(samlConnector, authPreference)
} else {
samlConnectors, err := authClient.GetSAMLConnectorsWithValidationOptions(ctx, false, types.SAMLConnectorValidationFollowURLs(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.logger.ErrorContext(r.Context(), "Failed to read traces request", "error", err)
w.WriteHeader(http.StatusBadRequest)
return nil, nil
}
if err := r.Body.Close(); err != nil {
h.logger.WarnContext(r.Context(), "Failed to close traces request body", "error", err)
}
var data tracepb.TracesData
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(body, &data); err != nil {
h.logger.ErrorContext(r.Context(), "Failed to unmarshal traces request", "error", err)
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.logger.ErrorContext(ctx, "Failed to upload traces", "error", err)
}
}()
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, h.logger)
if err != nil {
return nil, trace.Wrap(err)
}
proxyConfig, err := h.cfg.ProxySettings.GetProxySettings(r.Context())
if err != nil {
return nil, trace.Wrap(err)
}
pr, err := h.cfg.ProxyClient.Ping(r.Context())
if err != nil {
return nil, trace.Wrap(err)
}
group := r.URL.Query().Get(webclient.AgentUpdateGroupParameter)
return webclient.PingResponse{
Auth: authSettings,
Proxy: *proxyConfig,
ServerVersion: teleport.Version,
MinClientVersion: teleport.MinClientSemVer().String(),
ClusterName: h.auth.clusterName,
AutomaticUpgrades: pr.ServerFeatures.GetAutomaticUpgrades(),
AutoUpdate: h.automaticUpdateSettings184(r.Context(), group, "" /* updater UUID */),
Edition: modules.GetModules().BuildType(),
FIPS: modules.IsBoringBinary(),
}, nil
}
func (h *Handler) find(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
group := r.URL.Query().Get(webclient.AgentUpdateGroupParameter)
cacheKey := "find"
if group != "" {
cacheKey += "-" + group
}
// cache the generic answer to avoid doing work for each request
resp, err := utils.FnCacheGet(r.Context(), h.findEndpointCache, cacheKey, func(ctx context.Context) (*webclient.PingResponse, error) {
proxyConfig, err := h.cfg.ProxySettings.GetProxySettings(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
authPref, err := h.cfg.AccessPoint.GetAuthPreference(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
return &webclient.PingResponse{
Proxy: *proxyConfig,
Auth: webclient.AuthenticationSettings{SignatureAlgorithmSuite: authPref.GetSignatureAlgorithmSuite()},
ServerVersion: teleport.Version,
MinClientVersion: teleport.MinClientSemVer().String(),
ClusterName: h.auth.clusterName,
Edition: modules.GetModules().BuildType(),
FIPS: modules.IsBoringBinary(),
AutoUpdate: h.automaticUpdateSettings184(ctx, group, "" /* updater UUID */),
}, nil
})
if err != nil {
return nil, trace.Wrap(err)
}
return resp, 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,
MinClientVersion: teleport.MinClientSemVer().String(),
ClusterName: h.auth.clusterName,
}
hasMessageOfTheDay := cap.GetMessageOfTheDay() != ""
if slices.Contains(constants.SystemConnectors, connectorName) {
response.Auth, err = localSettings(r.Context(), cap, h.logger)
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.GetSAMLConnectorsWithValidationOptions(r.Context(), false, types.SAMLConnectorValidationFollowURLs(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.logger.ErrorContext(r.Context(), "Cannot retrieve OIDC connectors", "error", err)
}
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.GetSAMLConnectorsWithValidationOptions(r.Context(), false, types.SAMLConnectorValidationFollowURLs(false))
if err != nil {
h.logger.ErrorContext(r.Context(), "Cannot retrieve SAML connectors", "error", err)
}
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.logger.ErrorContext(r.Context(), "Cannot retrieve GitHub connectors", "error", err)
}
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.logger.ErrorContext(r.Context(), "Cannot retrieve AuthPreferences", "error", err)
authSettings = webclient.WebConfigAuthSettings{
Providers: authProviders,
SecondFactor: constants.SecondFactorOff,
LocalAuthEnabled: true,
AuthType: constants.Local,
}
} else {
authType := cap.GetType()
var localConnectorName string
var defaultConnectorName string
if authType == constants.Local {
localConnectorName = cap.GetConnectorName()
} else {
defaultConnectorName = cap.GetConnectorName()
}
authSettings = webclient.WebConfigAuthSettings{
Providers: authProviders,
SecondFactor: types.LegacySecondFactorFromSecondFactors(cap.GetSecondFactors()),
LocalAuthEnabled: cap.GetAllowLocalAuth(),
AllowPasswordless: cap.GetAllowPasswordless(),
AuthType: authType,
DefaultConnectorName: defaultConnectorName,
PreferredLocalMFA: cap.GetPreferredLocalMFA(),
LocalConnectorName: localConnectorName,
PrivateKeyPolicy: cap.GetPrivateKeyPolicy(),
MOTD: cap.GetMessageOfTheDay(),
}
}
clusterFeatures := h.GetClusterFeatures()
// get tunnel address to display on cloud instances
tunnelPublicAddr := ""
proxyConfig, err := h.cfg.ProxySettings.GetProxySettings(r.Context())
if err != nil {
h.logger.WarnContext(r.Context(), "Cannot retrieve ProxySettings, tunnel address won't be set in Web UI", "error", err)
} else {
if clusterFeatures.GetCloud() {
tunnelPublicAddr = proxyConfig.SSH.TunnelPublicAddr
}
}
// disable joining sessions if proxy session recording is enabled
canJoinSessions := true
recCfg, err := h.cfg.ProxyClient.GetSessionRecordingConfig(r.Context())
if err != nil {
h.logger.ErrorContext(r.Context(), "Cannot retrieve SessionRecordingConfig", "error", err)
} else {
canJoinSessions = !services.IsRecordAtProxy(recCfg.GetMode())
}
automaticUpgradesEnabled := clusterFeatures.GetAutomaticUpgrades()
var automaticUpgradesTargetVersion string
if automaticUpgradesEnabled {
const group, updaterUUID = "", ""
agentVersion, err := h.autoUpdateAgentVersion(r.Context(), group, updaterUUID)
if err != nil {
h.logger.ErrorContext(r.Context(), "Cannot read autoupdate target version", "error", err)
} else {
// agentVersion doesn't have the leading "v" which is expected here.
automaticUpgradesTargetVersion = fmt.Sprintf("v%s", agentVersion)
}
}
disableRoleVisualizer, _ := strconv.ParseBool(os.Getenv("TELEPORT_UNSTABLE_DISABLE_ROLE_VISUALIZER"))
webCfg := webclient.WebConfig{
Edition: modules.GetModules().BuildType(),
Auth: authSettings,
CanJoinSessions: canJoinSessions,
IsCloud: clusterFeatures.GetCloud(),
TunnelPublicAddress: tunnelPublicAddr,
RecoveryCodesEnabled: clusterFeatures.GetRecoveryCodes(),
UI: h.getUIConfig(r.Context()),
IsPolicyRoleVisualizerEnabled: !disableRoleVisualizer,
IsDashboard: services.IsDashboard(clusterFeatures),
IsTeam: false,
IsUsageBasedBilling: clusterFeatures.GetIsUsageBased(),
AutomaticUpgrades: automaticUpgradesEnabled,
AutomaticUpgradesTargetVersion: automaticUpgradesTargetVersion,
CustomTheme: clusterFeatures.GetCustomTheme(),
Questionnaire: clusterFeatures.GetQuestionnaire(),
IsStripeManaged: clusterFeatures.GetIsStripeManaged(),
PremiumSupport: clusterFeatures.GetSupportType() == proto.SupportType_SUPPORT_TYPE_PREMIUM,
PlayableDatabaseProtocols: player.SupportedDatabaseProtocols,
// Entitlements are reset/overridden in setEntitlementsWithLegacyLogic until setEntitlementsWithLegacyLogic is removed in v18
Entitlements: GetWebCfgEntitlements(clusterFeatures.GetEntitlements()),
}
// Set entitlements with backwards field compatibility
setEntitlementsWithLegacyLogic(&webCfg, clusterFeatures)
resource, err := h.cfg.ProxyClient.GetClusterName(r.Context())
if err != nil {
h.logger.WarnContext(r.Context(), "Failed to query cluster name", "error", err)
} 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
}
// setEntitlementsWithLegacyLogic ensures entitlements on webCfg are backwards compatible
// If Entitlements are present, will set the legacy fields equal to the equivalent entitlement value
// i.e. webCfg.IsIGSEnabled = clusterFeatures.Entitlements[entitlements.Identity].Enabled
// && webCfg.Entitlements[entitlements.Identity] = clusterFeatures.Entitlements[entitlements.Identity].Enabled
// If Entitlements are not present, will set the legacy fields AND the entitlement equal to the legacy feature
// i.e. webCfg.IsIGSEnabled = clusterFeatures.GetIdentityGovernance()
// && webCfg.Entitlements[entitlements.Identity] = clusterFeatures.GetIdentityGovernance()
// todo (michellescripts) remove in v18; & inline entitlement logic above
func setEntitlementsWithLegacyLogic(webCfg *webclient.WebConfig, clusterFeatures proto.Features) {
// if Entitlements are not present, GetWebCfgEntitlements will return a map of entitlement to {enabled:false}
// if Entitlements are present, GetWebCfgEntitlements will populate the fields appropriately
webCfg.Entitlements = GetWebCfgEntitlements(clusterFeatures.GetEntitlements())
if ent := clusterFeatures.GetEntitlements(); len(ent) > 0 {
// webCfg.Entitlements: No update as they are set above
// webCfg.<legacy fields>: set equal to entitlement value
webCfg.AccessRequests = modules.GetProtoEntitlement(&clusterFeatures, entitlements.AccessRequests).Enabled
webCfg.ExternalAuditStorage = modules.GetProtoEntitlement(&clusterFeatures, entitlements.ExternalAuditStorage).Enabled
webCfg.HideInaccessibleFeatures = modules.GetProtoEntitlement(&clusterFeatures, entitlements.FeatureHiding).Enabled
webCfg.IsIGSEnabled = modules.GetProtoEntitlement(&clusterFeatures, entitlements.Identity).Enabled
webCfg.IsPolicyEnabled = modules.GetProtoEntitlement(&clusterFeatures, entitlements.Policy).Enabled
webCfg.JoinActiveSessions = modules.GetProtoEntitlement(&clusterFeatures, entitlements.JoinActiveSessions).Enabled
webCfg.MobileDeviceManagement = modules.GetProtoEntitlement(&clusterFeatures, entitlements.MobileDeviceManagement).Enabled
webCfg.OIDC = modules.GetProtoEntitlement(&clusterFeatures, entitlements.OIDC).Enabled
webCfg.SAML = modules.GetProtoEntitlement(&clusterFeatures, entitlements.SAML).Enabled
webCfg.TrustedDevices = modules.GetProtoEntitlement(&clusterFeatures, entitlements.DeviceTrust).Enabled
webCfg.FeatureLimits = webclient.FeatureLimits{
AccessListCreateLimit: int(modules.GetProtoEntitlement(&clusterFeatures, entitlements.AccessLists).Limit),
AccessMonitoringMaxReportRangeLimit: int(modules.GetProtoEntitlement(&clusterFeatures, entitlements.AccessMonitoring).Limit),
AccessRequestMonthlyRequestLimit: int(modules.GetProtoEntitlement(&clusterFeatures, entitlements.AccessRequests).Limit),
}
} else {
// webCfg.Entitlements: All records are {enabled: false}; update to equal legacy feature value
webCfg.Entitlements[string(entitlements.ExternalAuditStorage)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetExternalAuditStorage()}
webCfg.Entitlements[string(entitlements.FeatureHiding)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetFeatureHiding()}
webCfg.Entitlements[string(entitlements.Identity)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetIdentityGovernance()}
webCfg.Entitlements[string(entitlements.JoinActiveSessions)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetJoinActiveSessions()}
webCfg.Entitlements[string(entitlements.MobileDeviceManagement)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetMobileDeviceManagement()}
webCfg.Entitlements[string(entitlements.OIDC)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetOIDC()}
webCfg.Entitlements[string(entitlements.Policy)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetPolicy() != nil && clusterFeatures.GetPolicy().Enabled}
webCfg.Entitlements[string(entitlements.SAML)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetSAML()}
// set default Identity fields to legacy feature value
webCfg.Entitlements[string(entitlements.AccessLists)] = webclient.EntitlementInfo{Enabled: true, Limit: clusterFeatures.GetAccessList().GetCreateLimit()}
webCfg.Entitlements[string(entitlements.AccessMonitoring)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetAccessMonitoring().GetEnabled(), Limit: clusterFeatures.GetAccessMonitoring().GetMaxReportRangeLimit()}
webCfg.Entitlements[string(entitlements.AccessRequests)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetAccessRequests().GetMonthlyRequestLimit() > 0, Limit: clusterFeatures.GetAccessRequests().GetMonthlyRequestLimit()}
webCfg.Entitlements[string(entitlements.DeviceTrust)] = webclient.EntitlementInfo{Enabled: clusterFeatures.GetDeviceTrust().GetEnabled(), Limit: clusterFeatures.GetDeviceTrust().GetDevicesUsageLimit()}
// override Identity Package features if Identity is enabled: set true and clear limit
if clusterFeatures.GetIdentityGovernance() {
webCfg.Entitlements[string(entitlements.AccessLists)] = webclient.EntitlementInfo{Enabled: true}
webCfg.Entitlements[string(entitlements.AccessMonitoring)] = webclient.EntitlementInfo{Enabled: true}
webCfg.Entitlements[string(entitlements.AccessRequests)] = webclient.EntitlementInfo{Enabled: true}
webCfg.Entitlements[string(entitlements.DeviceTrust)] = webclient.EntitlementInfo{Enabled: true}
webCfg.Entitlements[string(entitlements.OktaSCIM)] = webclient.EntitlementInfo{Enabled: true}
webCfg.Entitlements[string(entitlements.OktaUserSync)] = webclient.EntitlementInfo{Enabled: true}
webCfg.Entitlements[string(entitlements.SessionLocks)] = webclient.EntitlementInfo{Enabled: true}
}
// webCfg.<legacy fields>: set equal to legacy feature value
webCfg.AccessRequests = clusterFeatures.GetAccessRequests().GetMonthlyRequestLimit() > 0
webCfg.ExternalAuditStorage = clusterFeatures.GetExternalAuditStorage()
webCfg.HideInaccessibleFeatures = clusterFeatures.GetFeatureHiding()
webCfg.IsIGSEnabled = clusterFeatures.GetIdentityGovernance()
webCfg.IsPolicyEnabled = clusterFeatures.GetPolicy() != nil && clusterFeatures.GetPolicy().Enabled
webCfg.JoinActiveSessions = clusterFeatures.GetJoinActiveSessions()
webCfg.MobileDeviceManagement = clusterFeatures.GetMobileDeviceManagement()
webCfg.OIDC = clusterFeatures.GetOIDC()
webCfg.SAML = clusterFeatures.GetSAML()
webCfg.TrustedDevices = clusterFeatures.GetDeviceTrust().GetEnabled()
webCfg.FeatureLimits = webclient.FeatureLimits{
AccessListCreateLimit: int(clusterFeatures.GetAccessList().GetCreateLimit()),
AccessMonitoringMaxReportRangeLimit: int(clusterFeatures.GetAccessMonitoring().GetMaxReportRangeLimit()),
AccessRequestMonthlyRequestLimit: int(clusterFeatures.GetAccessRequests().GetMonthlyRequestLimit()),
}
}
}
// GetWebCfgEntitlements takes a cloud entitlement set and returns a modules Entitlement set
func GetWebCfgEntitlements(protoEntitlements map[string]*proto.EntitlementInfo) map[string]webclient.EntitlementInfo {
all := entitlements.AllEntitlements
result := make(map[string]webclient.EntitlementInfo, len(all))
for _, e := range all {
al, ok := protoEntitlements[string(e)]
if !ok {
result[string(e)] = webclient.EntitlementInfo{}
continue
}
result[string(e)] = webclient.EntitlementInfo{
Enabled: al.Enabled,
Limit: al.Limit,
}
}
return result
}
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, true)
}
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.logger.With("auth", "github")
logger.DebugContext(r.Context(), "Web login start")
req, err := ParseSSORequestParams(r)
if err != nil {
logger.ErrorContext(r.Context(), "Failed to extract SSO parameters from request", "error", err)
return client.LoginFailedRedirectURL
}
remoteAddr, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
logger.ErrorContext(r.Context(), "Failed to parse request remote address", "error", err)
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,
ClientUserAgent: r.UserAgent(),
})
if err != nil {
logger.ErrorContext(r.Context(), "Error creating auth request", "error", err)
return client.LoginFailedRedirectURL
}
return response.RedirectURL
}
func (h *Handler) githubLoginConsole(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
logger := h.logger.With("auth", "github")
logger.DebugContext(r.Context(), "Console login start")
req := new(client.SSOLoginConsoleReq)
if err := httplib.ReadResourceJSON(r, req); err != nil {
logger.ErrorContext(r.Context(), "Error reading json", "error", err)
return nil, trace.AccessDenied("%s", SSOLoginFailureMessage)
}
if err := req.CheckAndSetDefaults(); err != nil {
logger.ErrorContext(r.Context(), "Missing request parameters", "error", err)
return nil, trace.AccessDenied("%s", SSOLoginFailureMessage)
}
remoteAddr, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
logger.ErrorContext(r.Context(), "Failed to parse request remote address", "error", err)
return nil, trace.AccessDenied("%s", SSOLoginFailureMessage)
}
response, err := h.cfg.ProxyClient.CreateGithubAuthRequest(r.Context(), types.GithubAuthRequest{
ConnectorID: req.ConnectorID,
SshPublicKey: req.SSHPubKey,
TlsPublicKey: req.TLSPubKey,
SshAttestationStatement: req.SSHAttestationStatement.ToProto(),
TlsAttestationStatement: req.TLSAttestationStatement.ToProto(),
CertTTL: req.CertTTL,
ClientRedirectURL: req.RedirectURL,
Compatibility: req.Compatibility,
RouteToCluster: req.RouteToCluster,
KubernetesCluster: req.KubernetesCluster,
ClientLoginIP: remoteAddr,
})
if err != nil {
logger.ErrorContext(r.Context(), "Failed to create GitHub auth request", "error", err)
if strings.Contains(err.Error(), auth.InvalidClientRedirectErrorMessage) {
return nil, trace.AccessDenied("%s", SSOLoginFailureInvalidRedirect)
}
return nil, trace.AccessDenied("%s", SSOLoginFailureMessage)
}
return &client.SSOLoginConsoleResponse{
RedirectURL: response.RedirectURL,
}, nil
}
func (h *Handler) githubCallback(w http.ResponseWriter, r *http.Request, p httprouter.Params) string {
logger := h.logger.With("auth", "github")
logger.DebugContext(r.Context(), "Callback start", "query", r.URL.Query())
response, err := h.cfg.ProxyClient.ValidateGithubAuthCallback(r.Context(), r.URL.Query())
if err != nil {
logger.ErrorContext(r.Context(), "Error while processing callback", "error", err)
// 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.InfoContext(r.Context(), "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.ErrorContext(r.Context(), "Error setting web session.", "error", err)
return client.LoginFailedRedirectURL
}
if dwt := response.Session.GetDeviceWebToken(); dwt != nil {
logger.DebugContext(r.Context(), "GitHub WebSession created with device web token")
// if a device web token is present, we must send the user to the device authorize page
// to upgrade the session.
redirectPath, err := BuildDeviceWebRedirectPath(dwt, res.ClientRedirectURL)
if err != nil {
logger.DebugContext(r.Context(), "Invalid device web token", "error", err)
}
return redirectPath
}
return res.ClientRedirectURL
}
logger.InfoContext(r.Context(), "Callback is redirecting to console login")
if len(response.Req.SSHPubKey)+len(response.Req.TLSPubKey) == 0 {
logger.ErrorContext(r.Context(), "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.ErrorContext(r.Context(), "Error constructing ssh response", "error", err)
return client.LoginFailedRedirectURL
}
return redirectURL.String()
}
// BuildDeviceWebRedirectPath constructs the redirect path for device web authorization.
// It takes a DeviceWebToken and an optional client redirect URL as input.
// The function formats a redirect path with the device ID and token from the provided DeviceWebToken.
// If the clientRedirectURL is provided, it's appended to the redirect path
// as a query parameter named "redirect_uri".
// Will always at least return "/web" path.
func BuildDeviceWebRedirectPath(dwt *types.DeviceWebToken, clientRedirectURL string) (string, error) {
const basePath = "/web"
if dwt == nil {
return basePath, trace.BadParameter("DeviceWebToken cannot be nil")
}
if dwt.Id == "" || dwt.Token == "" {
return basePath, trace.BadParameter("DeviceWebToken ID and Token cannot be empty")
}
redirectPath := fmt.Sprintf("/web/device/authorize/%s/%s", dwt.Id, dwt.Token)
if clientRedirectURL != "" {
redirectPath = fmt.Sprintf("%s?redirect_uri=%s", redirectPath, clientRedirectURL)
}
return redirectPath, nil
}
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)
}
const group, agentUUD = "", ""
targetVersion, err := h.autoUpdateAgentVersion(r.Context(), group, agentUUD)
if err != nil {
h.logger.WarnContext(r.Context(), "Error retrieving the target version", "error", err)
targetVersion = teleport.SemVer()
}
instTmpl, err := template.New("").Parse(installer.GetScript())
if err != nil {
return nil, trace.Wrap(err)
}
feats := modules.GetModules().Features()
teleportPackage := types.PackageNameOSS
if modules.GetModules().BuildType() == modules.BuildEnterprise || feats.Cloud {
teleportPackage = types.PackageNameEnt
if h.cfg.FIPS {
teleportPackage = types.PackageNameEntFIPS
}
}
// By default, it uses the stable/v<majorVersion> channel.
repoChannel := fmt.Sprintf("stable/v%d", targetVersion.Major)
// 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 = automaticupgrades.DefaultCloudChannelName
}
azureClientID := r.URL.Query().Get("azure-client-id")
tmpl := installers.Template{
PublicProxyAddr: h.PublicProxyAddr(),
MajorVersion: shsprintf.EscapeDefaultContext(fmt.Sprintf("v%d", targetVersion.Major)),
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
// MFAToken is an SSO MFA token.
MFAToken string
}
// 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),
MFAToken: response.MFAToken,
}
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")
// We don't use a secret key for WebUI SSO MFA redirects. The request ID itself is
// kept a secret on the front end to minimize the risk of a phishing attack.
if secretKey == "" && u.Path == sso.WebMFARedirect && response.MFAToken != "" {
q := u.Query()
q.Add("response", string(out))
u.RawQuery = q.Encode()
return u, nil
}
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"`
// SessionExpiresIn is the seconds before the session itself expires.
SessionExpiresIn int `json:"sessionExpiresIn,omitempty"`
// 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"`
// TrustedDeviceRequirement calculated for the web session.
// Follows [types.TrustedDeviceRequirement].
TrustedDeviceRequirement int32 `json:"trustedDeviceRequirement,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)
}
now := sctx.cfg.Parent.clock.Now()
sessionExpiryTime := sctx.cfg.Session.GetExpiryTime()
return &CreateSessionResponse{
TokenType: roundtrip.AuthBearer,
Token: token.GetName(),
TokenExpiresIn: int(token.Expiry().Sub(now) / time.Second),
SessionExpiresIn: int(sessionExpiryTime.Sub(now) / time.Second),
SessionExpires: sessionExpiryTime,
SessionInactiveTimeoutMS: int(sctx.cfg.Session.GetIdleTimeout().Milliseconds()),
DeviceWebToken: sctx.cfg.Session.GetDeviceWebToken(),
TrustedDeviceRequirement: int32(sctx.cfg.Session.GetTrustedDeviceRequirement()),
}, 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.ReadResourceJSON(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 {
case !cap.IsSecondFactorEnforced():
webSession, err = h.auth.AuthWithoutOTP(r.Context(), req.User, req.Pass, clientMeta)
case req.SecondFactorToken == "" && !cap.IsSecondFactorEnforced():
webSession, err = h.auth.AuthWithoutOTP(r.Context(), req.User, req.Pass, clientMeta)
case cap.IsSecondFactorTOTPAllowed():
webSession, err = h.auth.AuthWithOTP(r.Context(), req.User, req.Pass, req.SecondFactorToken, clientMeta)
default:
return nil, trace.AccessDenied("direct login with password+otp not supported by this cluster")
}
if err != nil {
h.logger.WarnContext(r.Context(), "Access attempt denied for user", "user", req.User, "error", err)
// 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.logger.WarnContext(r.Context(), "Access attempt denied for user", "user", req.User, "error", err)
return nil, trace.AccessDenied("need auth")
}
res, err := newSessionResponse(ctx)
return res, trace.Wrap(err)
}
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) {
clt, err := ctx.GetClient()
if err != nil {
h.logger.WarnContext(r.Context(), "Failed to retrieve user client, SAML single logout will be skipped for user",
"user", ctx.GetUser(),
"error", err,
)
}
var user types.User
// Only run this if we successfully retrieved the client.
if err == nil {
user, err = clt.GetUser(r.Context(), ctx.GetUser(), false)
if err != nil {
h.logger.WarnContext(r.Context(), "Failed to retrieve user during logout, SAML single logout will be skipped for user",
"user", ctx.GetUser(),
"error", err,
)
}
}
if err := h.logout(r.Context(), w, ctx); err != nil {
return nil, trace.Wrap(err)
}
// If the user has SAML SLO (single logout) configured, return a redirect link to the SLO URL.
if user != nil && len(user.GetSAMLIdentities()) > 0 && user.GetSAMLIdentities()[0].SAMLSingleLogoutURL != "" {
// The WebUI will redirect the user to this URL to initiate the SAML SLO on the IdP side. This is safe because this URL
// is hard-coded in the auth connector and can't be modified by the end user. Additionally, the user's Teleport session has already
// been invalidated by this point so there is nothing to hijack.
return map[string]interface{}{"samlSloUrl": user.GetSAMLIdentities()[0].SAMLSingleLogoutURL}, nil
}
return OK(), nil
}
func (h *Handler) logout(ctx context.Context, w http.ResponseWriter, sctx *SessionContext) error {
if err := sctx.Invalidate(ctx); err != nil {
h.logger.WarnContext(ctx, "Failed to invalidate sessions",
"user", sctx.GetUser(),
"error", err,
)
}
if err := h.auth.releaseResources(ctx, sctx.GetUser(), sctx.GetSessionID()); err != nil {
h.logger.DebugContext(ctx, "sessionCache: Failed to release web session",
"session_id", sctx.GetSessionID(),
"error", 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)
}
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.ReadResourceJSON(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)
return res, trace.Wrap(err)
}
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.ReadResourceJSON(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.ReadResourceJSON(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.logger.WarnContext(r.Context(), "Failed to fetch a reset password token", "error", err)
// 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.ReadResourceJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
mfaReq := &proto.CreateAuthenticateChallengeRequest{}
if req.Passwordless {
mfaReq.Request = &proto.CreateAuthenticateChallengeRequest_Passwordless{
Passwordless: &proto.Passwordless{},
}
mfaReq.ChallengeExtensions = &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_PASSWORDLESS_LOGIN,
}
} else {
mfaReq.Request = &proto.CreateAuthenticateChallengeRequest_UserCredentials{
UserCredentials: &proto.UserCredentials{
Username: req.User,
Password: []byte(req.Pass),
},
}
mfaReq.ChallengeExtensions = &mfav1.ChallengeExtensions{
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
}
}
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, "" /*channelID*/), 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.ReadResourceJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
if err := req.CheckAndSetDefaults(); 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:
// log the actual error.
h.logger.WarnContext(r.Context(), "Login attempt denied for user", "user", req.User, "error", err)
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(r.Context())
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 getClusterInfoResponse struct {
ui.Cluster
IsCloud bool `json:"isCloud"`
}
// getClusterInfo returns the information about the cluster in the :site param
func (h *Handler) getClusterInfo(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) {
ctx := r.Context()
clusterDetails, err := ui.GetClusterDetails(ctx, site)
if err != nil {
return nil, trace.Wrap(err)
}
clt, err := sctx.GetUserClient(ctx, site)
if err != nil {
return nil, trace.Wrap(err)
}
pingResp, err := clt.Ping(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
return getClusterInfoResponse{
Cluster: *clusterDetails,
IsCloud: pingResp.GetServerFeatures().Cloud,
}, 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) {
return getSiteNamespacesResponse{
Namespaces: []types.Namespace{types.DefaultNamespace()},
}, 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,
types.KindSAMLIdPServiceProvider,
types.KindGitServer,
}
}
startKey := values.Get("startKey")
includeRequestable := values.Get("includedResourceMode") == IncludedResourceModeAll
useSearchAsRoles := values.Get("searchAsRoles") == "yes" || values.Get("includedResourceMode") == IncludedResourceModeRequestable
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"), ' '),
UseSearchAsRoles: useSearchAsRoles,
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, allowedLogins []string) ([]string, error) {
allowed := make(map[string]struct{})
for _, login := range allowedLogins {
allowed[login] = struct{}{}
}
var logins []string
for _, local := range identity.Principals {
if _, ok := allowed[local]; ok {
logins = append(logins, local)
}
}
slices.Sort(logins)
return logins, nil
}
// calculateAppLogins determines the app logins allowed for the provided
// resource.
//
// TODO(gabrielcorado): DELETE IN V18.0.0
// This is here for backward compatibility in case the auth server
// does not support enriched resources yet.
func calculateAppLogins(loginGetter loginGetter, r types.AppServer, allowedLogins []string) ([]string, error) {
if len(allowedLogins) > 0 {
return allowedLogins, nil
}
logins, err := loginGetter.GetAllowedLoginsForResource(r.GetApp())
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.logger.InfoContext(ctx, "Unable to fetch user groups while listing applications, unable to display associated user groups", "error", 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)
unifiedResources := make([]any, 0, len(page))
for _, enriched := range page {
switch r := enriched.ResourceWithLabels.(type) {
case types.Server:
switch enriched.GetKind() {
case types.KindNode:
logins, err := calculateSSHLogins(identity, enriched.Logins)
if err != nil {
return nil, trace.Wrap(err)
}
unifiedResources = append(unifiedResources, ui.MakeServer(site.GetName(), r, logins, enriched.RequiresRequest))
case types.KindGitServer:
unifiedResources = append(unifiedResources, ui.MakeGitServer(site.GetName(), r, enriched.RequiresRequest))
}
case types.DatabaseServer:
db := ui.MakeDatabaseFromDatabaseServer(r, accessChecker, h.cfg.DatabaseREPLRegistry, enriched.RequiresRequest)
unifiedResources = append(unifiedResources, db)
case types.AppServer:
allowedAWSRoles, err := calculateAppLogins(accessChecker, r, enriched.Logins)
if err != nil {
return nil, trace.Wrap(err)
}
allowedAWSRolesLookup := map[string][]string{
r.GetApp().GetName(): allowedAWSRoles,
}
proxyDNSName := h.proxyDNSName()
if r.GetApp().GetUseAnyProxyPublicAddr() {
// let the current proxy user is connected to override the dns name
proxyDNSName = utils.FindMatchingProxyDNS(request.Host, h.proxyDNSNames())
}
app := ui.MakeApp(r.GetApp(), ui.MakeAppsConfig{
LocalClusterName: h.auth.clusterName,
LocalProxyDNSName: proxyDNSName,
AppClusterName: site.GetName(),
AllowedAWSRolesLookup: allowedAWSRolesLookup,
UserGroupLookup: getUserGroupLookup(),
Logger: h.logger,
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:
unifiedResources = append(unifiedResources, ui.MakeDesktop(r, enriched.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)
}
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, 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(), &notificationsv1.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.ReadResourceJSON(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(), &notificationsv1.UpsertUserLastSeenNotificationRequest{
Username: sctx.GetUser(),
UserLastSeenNotification: &notificationsv1.UserLastSeenNotification{
Status: &notificationsv1.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.ReadResourceJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
resp, err := clt.NotificationServiceClient().UpsertUserNotificationState(r.Context(), &notificationsv1.UpsertUserNotificationStateRequest{
Username: sctx.GetUser(),
UserNotificationState: &notificationsv1.UserNotificationState{
Spec: &notificationsv1.UserNotificationStateSpec{
NotificationId: req.NotificationId,
},
Status: &notificationsv1.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.ReadResourceJSON(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&params=<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(r.Context(), &req, clusterName, sessionCtx)
if err != nil {
h.logger.DebugContext(r.Context(), "Unable to generate new ssh session", "error", err)
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.logger.DebugContext(r.Context(), "New terminal request",
"server", req.Server,
"login", req.Login,
"sid", sessionData.ID,
"websid", sessionCtx.GetSessionID(),
)
authAccessPoint, err := site.CachingAccessPoint()
if err != nil {
h.logger.DebugContext(r.Context(), "Unable to get auth access point", "error", err)
return nil, trace.Wrap(err)
}
dialTimeout := apidefaults.DefaultIOTimeout
keepAliveInterval := apidefaults.KeepAliveInterval()
if netConfig, err := authAccessPoint.GetClusterNetworkingConfig(ctx); err != nil {
h.logger.DebugContext(r.Context(), "Unable to fetch cluster networking config", "error", err)
} else {
dialTimeout = netConfig.GetSSHDialTimeout()
keepAliveInterval = netConfig.GetKeepAliveInterval()
}
// 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 req.KeepAliveInterval >= time.Second {
keepAliveInterval = req.KeepAliveInterval
}
nw, err := site.NodeWatcher()
if err != nil {
return nil, trace.Wrap(err)
}
term, err := NewTerminal(ctx, TerminalHandlerConfig{
Logger: h.logger,
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,
SSHDialTimeout: dialTimeout,
HostNameResolver: func(serverID string) (string, error) {
matches, err := nw.CurrentResourcesWithFilter(r.Context(), func(n readonly.Server) bool {
return n.GetName() == serverID
})
if err != nil {
return "", trace.Wrap(err)
}
if len(matches) != 1 {
return "", trace.NotFound("unable to resolve hostname for server %s", serverID)
}
return matches[0].GetHostname(), nil
},
})
if err != nil {
h.logger.ErrorContext(r.Context(), "Unable to create terminal", "error", err)
return nil, trace.Wrap(err)
}
h.userConns.Add(1)
defer h.userConns.Add(-1)
// start the websocket session with a web-based terminal:
httplib.MakeTracingHandler(term, teleport.ComponentProxy).ServeHTTP(w, r)
return nil, nil
}
func (h *Handler) setDefaultConnectorHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
var req ui.SetDefaultAuthConnectorRequest
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)
}
authPref, err := clt.GetAuthPreference(r.Context())
if err != nil {
return nil, trace.Wrap(err, "failed to get auth preference")
}
authPref.SetConnectorName(req.Name)
authPref.SetType(req.Type)
_, err = clt.UpsertAuthPreference(r.Context(), authPref)
if err != nil {
return nil, trace.Wrap(err)
}
return OK(), nil
}
type podConnectParams struct {
// Term is the initial PTY size.
Term session.TerminalParams `json:"term"`
// SessionID is a Teleport session ID to join as.
SessionID session.ID `json:"sid"`
// ParticipantMode is the mode that determines what you can do when you join an active session.
ParticipantMode types.SessionParticipantMode `json:"mode"`
}
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()
if q.Get("params") == "" {
return nil, trace.BadParameter("missing params")
}
var params podConnectParams
if err := json.Unmarshal([]byte(q.Get("params")), &params); err != nil {
return nil, trace.Wrap(err)
}
// If a session is provided, then join an existing session
// instead of creating a new one.
if !params.SessionID.IsZero() {
return nil, trace.Wrap(h.joinKubernetesSession(
r.Context(),
params.SessionID.String(),
params.ParticipantMode,
sctx,
site,
ws,
))
}
// Wait for the user to supply the pod information.
execReq, err := readPodExecRequestFromWS(ws)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || terminal.IsOKWebsocketCloseError(trace.Unwrap(err)) {
return nil, nil
}
var netError net.Error
if errors.As(trace.Unwrap(err), &netError) && netError.Timeout() {
return nil, trace.BadParameter("timed out waiting for pod exec request data on websocket connection")
}
return nil, trace.Wrap(err)
}
execReq.Term = params.Term
if err := execReq.Validate(); err != nil {
return nil, trace.Wrap(err)
}
sess := session.Session{
Kind: types.KubernetesSessionKind,
Login: "root",
ClusterName: site.GetName(),
KubernetesClusterName: execReq.KubeCluster,
ID: session.NewID(),
Created: h.clock.Now().UTC(),
LastActive: h.clock.Now().UTC(),
Namespace: apidefaults.Namespace,
Owner: sctx.GetUser(),
Command: execReq.Command,
}
h.logger.DebugContext(r.Context(), "New kube exec request",
"namespace", execReq.Namespace,
"pod", execReq.Pod,
"container", execReq.Container,
"sid", sess.ID,
"websid", 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)
}
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)
}
clt, err := sctx.GetUserClient(r.Context(), site)
if err != nil {
return nil, trace.Wrap(err)
}
ph := podExecHandler{
req: execReq,
sess: sess,
sctx: sctx,
teleportCluster: site.GetName(),
ws: ws,
keepAliveInterval: netConfig.GetKeepAliveInterval(),
logger: h.logger.With(teleport.ComponentKey, "pod"),
userClient: clt,
localCA: hostCA,
configServerAddr: serverAddr,
configTLSServerName: tlsServerName,
publicProxyAddr: h.cfg.PublicProxyAddr,
}
ph.ServeHTTP(w, r)
return nil, nil
}
// KubeExecDataWaitTimeout is how long server would wait for user to send pod exec data (namespace, pod name etc)
// on websocket connection, after user initiated the exec into pod flow.
const KubeExecDataWaitTimeout = defaults.HeadlessLoginTimeout
func readPodExecRequestFromWS(ws *websocket.Conn) (*PodExecRequest, error) {
err := ws.SetReadDeadline(time.Now().Add(KubeExecDataWaitTimeout))
if err != nil {
return nil, trace.Wrap(err, "failed to set read deadline for websocket connection")
}
messageType, bytes, err := ws.ReadMessage()
if err != nil {
return nil, trace.Wrap(err)
}
if err := ws.SetReadDeadline(time.Time{}); err != nil {
return nil, trace.Wrap(err, "failed to set read deadline for websocket connection")
}
if messageType != websocket.BinaryMessage {
return nil, trace.BadParameter("Expected binary message of type websocket.BinaryMessage, got %v", messageType)
}
var envelope terminal.Envelope
if err := gogoproto.Unmarshal(bytes, &envelope); err != nil {
return nil, trace.BadParameter("Failed to parse envelope: %v", err)
}
var req PodExecRequest
if err := json.Unmarshal([]byte(envelope.Payload), &req); err != nil {
return nil, trace.Wrap(err)
}
return &req, 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(ctx context.Context, req *TerminalRequest, clusterName string, scx *SessionContext) (session.Session, error) {
owner := scx.cfg.User
h.logger.InfoContext(ctx, "Generating new session", "cluster", 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(),
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.logger.InfoContext(ctx, "Attempting to join existing session", "session_id", 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
}
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)
}
}
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"`
}
// 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
}
// headlessLogin is a web call that perform headless login based on a user's
// name, returning new login certs if successful.
//
// POST /v1/webapi/headless/login
//
// { "user": "bob", "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) headlessLogin(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
var req client.HeadlessLoginReq
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
if err := req.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
authClient := h.cfg.ProxyClient
authSSHUserReq := authclient.AuthenticateSSHRequest{
AuthenticateUserRequest: authclient.AuthenticateUserRequest{
Username: req.User,
SSHPublicKey: req.SSHPubKey,
TLSPublicKey: req.TLSPubKey,
ClientMetadata: clientMetaFromReq(r),
HeadlessAuthenticationID: req.HeadlessAuthenticationID,
},
CompatibilityMode: req.Compatibility,
TTL: req.TTL,
RouteToCluster: req.RouteToCluster,
KubernetesCluster: req.KubernetesCluster,
SSHAttestationStatement: req.SSHAttestationStatement,
TLSAttestationStatement: req.TLSAttestationStatement,
}
// 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)
}
}
loginResp, err := httpClient.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.logger.ErrorContext(r.Context(), "Failed validating trusted cluster", "error", err)
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.
//
// WithClusterAuth also provides CSRF protection by requiring the bearer token to be present.
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(ctx context.Context, ws *websocket.Conn, err error) {
if err == nil {
return
}
errEnvelope := terminal.Envelope{
Version: defaults.WebsocketVersion,
Type: defaults.WebsocketError,
Payload: trace.UserMessage(err),
}
env, err := errEnvelope.Marshal()
if err != nil {
h.logger.ErrorContext(ctx, "error marshaling proto", "error", err)
return
}
if err := ws.WriteMessage(websocket.BinaryMessage, env); err != nil {
h.logger.ErrorContext(ctx, "error writing proto", "error", err)
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 },
// We’re disabling the error handler here since error handling is managed within the handler itself.
// Allowing the WS error handler to operate would result in it writing to the response writer,
// which conflicts with our custom error handler and leads to unintended errors.
Error: func(http.ResponseWriter, *http.Request, int, error) {},
}
// WithClusterAuthWebSocket wraps a ClusterWebsocketHandler to ensure that a request is authenticated
// to this proxy via websocket, 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) WithClusterAuthWebSocket(fn ClusterWebsocketHandler) httprouter.Handle {
return httplib.MakeHandler(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (any, error) {
sctx, ws, site, err := h.authenticateWSRequestWithCluster(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(r.Context(), 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(r.Context(), sctx, p)
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(r.Context(), 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(ctx context.Context, sctx *SessionContext, p httprouter.Params) (reversetunnelclient.RemoteSite, error) {
clusterName := p.ByName("site")
site, err := h.getSiteByClusterName(ctx, sctx, clusterName)
if err != nil {
return nil, trace.Wrap(err)
}
return site, nil
}
func (h *Handler) getSiteByClusterName(ctx context.Context, sctx *SessionContext, clusterName string) (reversetunnelclient.RemoteSite, error) {
if clusterName == currentSiteShortcut {
res, err := h.cfg.ProxyClient.GetClusterName(ctx)
if err != nil {
h.logger.WarnContext(ctx, "Failed to query cluster name", "error", err)
return nil, trace.Wrap(err)
}
clusterName = res.GetClusterName()
}
proxy, err := h.ProxyWithRoles(ctx, sctx)
if err != nil {
h.logger.WarnContext(ctx, "Failed to get proxy with roles", "error", err)
return nil, trace.Wrap(err)
}
site, err := proxy.GetSite(clusterName)
if err != nil {
h.logger.WarnContext(ctx, "Failed to query site", "error", err, "cluster", clusterName)
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(ctx, 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.logger.WarnContext(ctx, "Failed to query cluster", "error", err, "cluster", h.auth.clusterName)
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)
// IsValidRedirectURL validates redirect URL.
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.logger.WarnContext(r.Context(), "Failed to issue a redirect", "error", err)
}
}
}
// WithAuth ensures that a request is authenticated.
// Authenticated requests require both a session cookie as well as a bearer token.
// WithAuth also provides CSRF protection by requiring the bearer token to be present.
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 /* check bearer token */)
if err != nil {
return nil, trace.Wrap(err)
}
return fn(w, r, p, sctx)
})
}
// WithSession ensures that the request provides a session cookie.
// It does not check for a bearer token.
//
// WithSession does not provide CSRF protection, so it should only
// be used for non-state-changing requests or when other CSRF mitigations
// are applied.
func (h *Handler) WithSession(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, false /* check bearer token */)
if err != nil {
return nil, trace.Wrap(err)
}
return fn(w, r, p, sctx)
})
}
// 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)
}
return trace.Wrap(limiter.RegisterRequest(remote, nil /* customRate */))
}
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("%s", 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 != "" {
mfaResp, err := client.ParseMFAChallengeResponse([]byte(mfaResponseJSON))
if err != nil {
return nil, trace.Wrap(err)
}
return mfa.ContextWithMFAResponse(ctx, mfaResp), 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 {
h.logger.ErrorContext(r.Context(), "Error while writing invalid token error to websocket", "error", 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 context.Context, sctx *SessionContext) (reversetunnelclient.Tunnel, error) {
accessChecker, err := sctx.GetUserAccessChecker()
if err != nil {
h.logger.WarnContext(ctx, "Failed to get client roles", "error", err)
return nil, trace.Wrap(err)
}
cn, err := h.cfg.AccessPoint.GetClusterName(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
return reversetunnelclient.NewTunnelWithRoles(h.cfg.Proxy, cn.GetClusterName(), accessChecker.CheckAccessToRemoteCluster, 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 used to protect against login-CSRF in SSO flows.
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
// MFAToken is an SSO MFA token.
MFAToken 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 {
// Make sure that the CSRF token provided in this request matches
// the token that was used to initiate this SSO attempt.
//
// This ensures that an attacker cannot perform a login CSRF attack
// in order to get the victim to log in to the incorrect account.
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
}
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
}
// WithAuthCookieAndCSRF ensures that a request is authenticated
// for plain old non-AJAX requests (does not check the Bearer header).
// It enforces CSRF checks.
//
// TODO(kimlisa): DELETE IN v19.0 (csrf)
// Deprecated: do not use (only here to support backwards compat for old plugin endpoints)
func (h *Handler) WithAuthCookieAndCSRF(fn ContextHandler) httprouter.Handle {
// formFieldName is the default form field to inspect.
const formFieldName = "csrf_token"
// verifyFormField checks if HTTP form value matches the cookie.
verifyFormField := func(r *http.Request) error {
token := r.FormValue(formFieldName)
if len(token) == 0 {
return trace.BadParameter("cannot retrieve CSRF token from form field %q", formFieldName)
}
err := csrf.VerifyToken(token, r)
if err != nil {
return trace.Wrap(err)
}
return nil
}
return httplib.MakeHandler(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)
}
errForm := verifyFormField(r)
if errForm != nil {
slog.WarnContext(r.Context(), "unable to validate CSRF token", "form_error", errForm)
return nil, trace.AccessDenied("access denied")
}
return fn(w, r, p, sctx)
})
}