Files
teleport/lib/web/apiserver.go
T
rosstimothy f27f701323 Reduce tsh binary size (#41743)
* Remove lib/web dependency from tsh

Refactors the terminal stream code in lib/web into a subpackage
so that tsh no longer depends on the entire lib/web package.

* Remove lib/auth dependency from tsh

Refactors invalid credentials errors and helpers into lib/auth/authclient
so that tsh no longer imports lib/auth directly.

* fix find-replace typos
2024-05-21 19:42:27 +00:00

4925 lines
176 KiB
Go

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