Files
teleport/lib/web/apiserver.go
T
Brian Joerger 5a3a374203 Decouple SkipLocalAuth, UseKeyPrincipal, and static auth methods. (#21182)
* Fix TestDiagnoseSSHConnection to be less strict on the exact unknown user error message, which may vary between OS's.

* Decouple/refactor SkipLocalAuth, UseKeyPrincipal, and static auth methods.

* Resolve comments.

* * Add tests for new methods

* Update proxy ssh principal test to catch regression

* Bump predicate error in switch case priority
2023-03-14 23:22:08 +00:00

3729 lines
126 KiB
Go

/*
Copyright 2015-2021 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// 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"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/gravitational/oxy/ratelimit"
"github.com/gravitational/roundtrip"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/julienschmidt/httprouter"
lemma_secret "github.com/mailgun/lemma/secret"
"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/exp/slices"
"golang.org/x/mod/semver"
"google.golang.org/protobuf/encoding/protojson"
"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"
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"
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
"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"
"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/plugin"
"github.com/gravitational/teleport/lib/proxy"
"github.com/gravitational/teleport/lib/reversetunnel"
"github.com/gravitational/teleport/lib/secret"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/web/app"
"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."
metaRedirectHTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<title>Teleport Redirection Service</title>
<meta http-equiv="cache-control" content="no-cache"/>
<meta http-equiv="refresh" content="0;URL='{{.}}'" />
</head>
<body></body>
</html>
`
)
var metaRedirectTemplate = template.Must(template.New("meta-redirect").Parse(metaRedirectHTML))
// 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
healthCheckAppServer healthCheckAppServerFunc
// sshPort specifies the SSH proxy port extracted
// from configuration
sshPort string
// userConns tracks amount of current active connections with user certificates.
userConns atomic.Int32
// ClusterFeatures contain flags for supported and unsupported features.
ClusterFeatures proto.Features
}
// 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)
}
// 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 reversetunnel.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 auth.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
// 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 auth.ProxyAccessPoint
// Emitter is event emitter
Emitter events.StreamEmitter
// 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 *srv.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
}
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.HasSession(r) || app.HasClientCert(r)) {
h.appHandler.ServeHTTP(w, r)
return
}
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.ProxyClient = auth.WithGithubConnectorConversions(cfg.ProxyClient)
h := &Handler{
cfg: cfg,
log: newPackageLogger(),
clock: clockwork.NewRealClock(),
ClusterFeatures: cfg.ClusterFeatures,
healthCheckAppServer: cfg.HealthCheckAppServer,
}
// 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,
})
if err != nil {
return nil, trace.Wrap(err)
}
h.auth = sessionCache
_, 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)
sshPort = strconv.Itoa(defaults.SSHProxyListenPort)
}
h.sshPort = sshPort
// 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.LimiterPasswordlessPeriod,
Average: defaults.LimiterPasswordlessAverage,
Burst: defaults.LimiterPasswordlessBurst,
},
},
MaxConnections: defaults.LimiterMaxConnections,
MaxNumberOfUsers: defaults.LimiterMaxConcurrentUsers,
})
if err != nil {
return nil, trace.Wrap(err)
}
if cfg.MinimalReverseTunnelRoutesOnly {
h.bindMinimalEndpoints()
} else {
h.bindDefaultEndpoints()
}
// if Web UI is enabled, check the assets dir:
var indexPage *template.Template
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", "/web/config.js", httplib.MakeHandler(h.getWebConfig))
}
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") {
fs := http.FileServer(cfg.StaticFS)
fs = makeGzipHandler(fs)
fs = makeCacheHandler(fs)
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.WithError(err).Debug("Could not authenticate.")
}
session.XCSRF = csrfToken
httplib.SetNoCacheHeaders(w.Header())
// app access needs to make a CORS fetch request, so we only set the default CSP on that page
if strings.HasPrefix(r.URL.Path, "/web/launch") {
parts := strings.Split(r.URL.Path, "/")
// grab the FQDN from the URL to allow in the connect-src CSP
applicationURL := "https://" + parts[3] + ":*"
httplib.SetAppLaunchContentSecurityPolicy(w.Header(), applicationURL)
} else {
httplib.SetIndexContentSecurityPolicy(w.Header())
}
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,
})
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
h.GET("/webapi/find", httplib.MakeHandler(h.find))
// Issue host credentials.
h.POST("/webapi/host/credentials", httplib.MakeHandler(h.hostCredentials))
}
// bindDefaultEndpoints binds the default endpoints for the web API.
func (h *Handler) bindDefaultEndpoints() {
h.bindMinimalEndpoints()
// ping endpoint is used to check if the server is up. the /webapi/ping
// endpoint returns the default authentication method and configuration that
// the server supports. the /webapi/ping/:connector endpoint can be used to
// query the authentication configuration for a specific connector.
h.GET("/webapi/ping", httplib.MakeHandler(h.ping))
h.GET("/webapi/ping/:connector", httplib.MakeHandler(h.pingWithConnector))
// Unauthenticated access to JWT public keys.
h.GET("/.well-known/jwks.json", httplib.MakeHandler(h.jwks))
// Unauthenticated access to the message of the day
h.GET("/webapi/motd", httplib.MakeHandler(h.motd))
// Unauthenticated access to retrieving the script used to install
// Teleport
h.GET("/webapi/scripts/installer/:name", httplib.MakeHandler(h.installer))
// desktop access configuration scripts
h.GET("/webapi/scripts/desktop-access/install-ad-ds.ps1", httplib.MakeHandler(h.desktopAccessScriptInstallADDSHandle))
h.GET("/webapi/scripts/desktop-access/install-ad-cs.ps1", httplib.MakeHandler(h.desktopAccessScriptInstallADCSHandle))
h.GET("/webapi/scripts/desktop-access/configure/:token/configure-ad.ps1", httplib.MakeHandler(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))
// DELETE IN 13, deprecated/unused web sessions routes (avatus)
// https://github.com/gravitational/teleport/pull/19892
h.POST("/webapi/sessions", httplib.WithCSRFProtection(h.WithLimiterHandlerFunc(h.createWebSession)))
h.DELETE("/webapi/sessions", h.WithAuth(h.deleteWebSession))
h.POST("/webapi/sessions/renew", h.WithAuth(h.renewWebSession))
// 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", httplib.MakeHandler(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.WithLimiter(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 nodes
h.GET("/webapi/sites/:site/nodes", h.WithClusterAuth(h.clusterNodesGet))
// 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))
// active sessions handlers
h.GET("/webapi/sites/:site/connect", h.WithClusterAuth(h.siteNodeConnect)) // connect to an active session (via websocket)
h.GET("/webapi/sites/:site/sessions", h.WithClusterAuth(h.siteSessionsGet)) // get active list of sessions
// TODO POSTS to `/webapi/sites/:site/sessions` should no longer be required
// but this endpoint is still used by the UI. When time allows evaluate the
// removal of this handler and the associated methods here and in the UI.
h.POST("/webapi/sites/:site/sessions", h.WithClusterAuth(h.siteSessionGenerate)) // create active session metadata
h.GET("/webapi/sites/:site/sessions/:sid", h.WithClusterAuth(h.siteSessionGet)) // get active session metadata
// 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/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
h.GET("/webapi/sites/:site/auth/export", h.authExportPublic)
// token generation
h.POST("/webapi/token", h.WithAuth(h.createTokenHandle))
// join scripts
h.GET("/scripts/:token/install-node.sh", httplib.MakeHandler(h.getNodeJoinScriptHandle))
h.GET("/scripts/:token/install-app.sh", httplib.MakeHandler(h.getAppJoinScriptHandle))
h.GET("/scripts/:token/install-database.sh", httplib.MakeHandler(h.getDatabaseJoinScriptHandle))
// 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))
// 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", httplib.MakeHandler(h.mfaLoginFinish))
h.POST("/webapi/mfa/login/finishsession", httplib.MakeHandler(h.mfaLoginFinishSession))
h.DELETE("/webapi/mfa/token/:token/devices/:devicename", httplib.MakeHandler(h.deleteMFADeviceWithTokenHandle))
h.GET("/webapi/mfa/token/:token/devices", httplib.MakeHandler(h.getMFADevicesWithTokenHandle))
h.POST("/webapi/mfa/token/:token/authenticatechallenge", httplib.MakeHandler(h.createAuthenticateChallengeWithTokenHandle))
h.POST("/webapi/mfa/token/:token/registerchallenge", httplib.MakeHandler(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))
h.POST("/webapi/mfa/authenticatechallenge/password", h.WithAuth(h.createAuthenticateChallengeWithPassword))
// trusted clusters
h.POST("/webapi/trustedclusters/validate", httplib.MakeHandler(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.getRolesHandle))
h.POST("/webapi/roles", h.WithAuth(h.upsertRoleHandle))
h.PUT("/webapi/roles/:name", h.WithAuth(h.upsertRoleHandle))
h.DELETE("/webapi/roles/:name", h.WithAuth(h.deleteRole))
h.GET("/webapi/github", h.WithAuth(h.getGithubConnectorsHandle))
h.POST("/webapi/github", h.WithAuth(h.upsertGithubConnectorHandle))
h.PUT("/webapi/github/:name", h.WithAuth(h.upsertGithubConnectorHandle))
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))
// 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>
h.GET("/webapi/sites/:site/desktops/:desktopName/connect", h.WithClusterAuth(h.desktopConnectHandle))
// GET /webapi/sites/:site/desktopplayback/:sid?access_token=<bearer_token>
h.GET("/webapi/sites/:site/desktopplayback/:sid", h.WithClusterAuth(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))
// Connection upgrades.
h.GET("/webapi/connectionupgrade", httplib.MakeHandler(h.connectionUpgrade))
// create user events.
h.POST("/webapi/precapture", h.WithLimiter(h.createPreUserEventHandle))
// create authenticated user events.
h.POST("/webapi/capture", h.WithAuth(h.createUserEventHandle))
}
// GetProxyClient returns authenticated auth server client
func (h *Handler) GetProxyClient() auth.ClientI {
return h.cfg.ProxyClient
}
// GetAccessPoint returns the caching access point.
func (h *Handler) GetAccessPoint() auth.ProxyAccessPoint {
return h.cfg.AccessPoint
}
// Close closes associated session cache operations
func (h *Handler) Close() error {
return h.auth.Close()
}
func (h *Handler) getUserStatus(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) {
return 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 reversetunnel.RemoteSite) (interface{}, 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(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
userContext, err := ui.NewUserContext(user, accessChecker.Roles(), h.ClusterFeatures, desktopRecordingEnabled)
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.Cluster, err = ui.GetClusterDetails(r.Context(), site)
if err != nil {
return nil, trace.Wrap(err)
}
userContext.ConsumedAccessRequestID = c.cfg.Session.GetConsumedAccessRequestID()
return userContext, nil
}
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(),
DeviceTrustDisabled: deviceTrustDisabled(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(),
DeviceTrustDisabled: deviceTrustDisabled(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(),
DeviceTrustDisabled: deviceTrustDisabled(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(),
DeviceTrustDisabled: deviceTrustDisabled(cap),
}
}
// 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 auth.ClientI) (webclient.AuthenticationSettings, error) {
cap, err := authClient.GetAuthPreference(ctx)
if err != nil {
return webclient.AuthenticationSettings{}, trace.Wrap(err)
}
var as webclient.AuthenticationSettings
switch cap.GetType() {
case constants.Local:
as, err = localSettings(cap)
if err != nil {
return webclient.AuthenticationSettings{}, trace.Wrap(err)
}
case constants.OIDC:
if cap.GetConnectorName() != "" {
oidcConnector, err := authClient.GetOIDCConnector(ctx, cap.GetConnectorName(), false)
if err != nil {
return webclient.AuthenticationSettings{}, trace.Wrap(err)
}
as = oidcSettings(oidcConnector, cap)
} 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], cap)
}
case constants.SAML:
if cap.GetConnectorName() != "" {
samlConnector, err := authClient.GetSAMLConnector(ctx, cap.GetConnectorName(), false)
if err != nil {
return webclient.AuthenticationSettings{}, trace.Wrap(err)
}
as = samlSettings(samlConnector, cap)
} 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], cap)
}
case constants.Github:
if cap.GetConnectorName() != "" {
githubConnector, err := authClient.GetGithubConnector(ctx, cap.GetConnectorName(), false)
if err != nil {
return webclient.AuthenticationSettings{}, trace.Wrap(err)
}
as = githubSettings(githubConnector, cap)
} 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], cap)
}
default:
return webclient.AuthenticationSettings{}, trace.BadParameter("unknown type %v", cap.GetType())
}
as.HasMessageOfTheDay = cap.GetMessageOfTheDay() != ""
pingResp, err := authClient.Ping(ctx)
if err != nil {
return webclient.AuthenticationSettings{}, trace.Wrap(err)
}
as.LoadAllCAs = pingResp.LoadAllCAs
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 := io.ReadAll(io.LimitReader(r.Body, teleport.MaxHTTPRequestSize))
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() {
if err := h.cfg.TraceClient.UploadTraces(r.Context(), 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)
}
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,
LicenseWarnings: pr.LicenseWarnings,
}, nil
}
func (h *Handler) find(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
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(),
}
}
// get tunnel address to display on cloud instances
tunnelPublicAddr := ""
if h.ClusterFeatures.GetCloud() {
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 {
tunnelPublicAddr = proxyConfig.SSH.TunnelPublicAddr
}
}
// disable joining sessions if proxy session recording is enabled
canJoinSessions := true
recCfg, err := h.cfg.ProxyClient.GetSessionRecordingConfig(r.Context())
if err != nil {
h.log.WithError(err).Error("Cannot retrieve SessionRecordingConfig.")
} else {
canJoinSessions = !services.IsRecordAtProxy(recCfg.GetMode())
}
webCfg := webclient.WebConfig{
Auth: authSettings,
CanJoinSessions: canJoinSessions,
IsCloud: h.ClusterFeatures.GetCloud(),
TunnelPublicAddress: tunnelPublicAddr,
RecoveryCodesEnabled: h.ClusterFeatures.GetRecoveryCodes(),
UI: h.getUIConfig(r.Context()),
IsDashboard: isDashboard(h.ClusterFeatures),
}
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()),
}
}
return h.cfg.UI
}
// jwks returns all public keys used to sign JWT tokens for this cluster.
func (h *Handler) jwks(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) {
clusterName, err := h.cfg.ProxyClient.GetDomainName(r.Context())
if err != nil {
return nil, trace.Wrap(err)
}
// Fetch the JWT public keys only.
ca, err := h.cfg.ProxyClient.GetCertAuthority(r.Context(), types.CertAuthID{
Type: types.JWTSigner,
DomainName: clusterName,
}, false)
if err != nil {
return nil, trace.Wrap(err)
}
pairs := ca.GetTrustedJWTKeyPairs()
// Create response and allocate space for the keys.
var resp JWKSResponse
resp.Keys = make([]jwt.JWK, 0, len(pairs))
// Loop over and all add public keys in JWK format.
for _, pair := range pairs {
jwk, err := jwt.MarshalJWK(pair.PublicKey)
if err != nil {
return nil, trace.Wrap(err)
}
resp.Keys = append(resp.Keys, jwk)
}
return &resp, nil
}
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
}
response, err := h.cfg.ProxyClient.CreateGithubAuthRequest(r.Context(), types.GithubAuthRequest{
CSRFToken: req.CSRFToken,
ConnectorID: req.ConnectorID,
CreateWebSession: true,
ClientRedirectURL: req.ClientRedirectURL,
})
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)
}
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(),
})
if err != nil {
logger.WithError(err).Error("Failed to create Github auth request.")
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)
}
tmpl := installers.Template{
PublicProxyAddr: h.cfg.PublicProxyAddr,
MajorVersion: version,
TeleportPackage: teleportPackage,
}
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 := auth.SSHLoginResponse{
Username: response.Username,
Cert: response.Cert,
TLSCert: response.TLSCert,
HostSigners: auth.AuthoritiesToTrustedCerts(response.HostSigners),
}
out, err := json.Marshal(consoleResponse)
if err != nil {
return nil, trace.Wrap(err)
}
// Extract secret out of the request. Look for both "secret" which is the
// old format and "secret_key" which is the new fomat. If this is not done,
// then users would have to update their callback URL in their identity
// provider.
values := u.Query()
secretV1 := values.Get("secret")
secretV2 := values.Get("secret_key")
values.Set("secret", "")
values.Set("secret_key", "")
var ciphertext []byte
switch {
// AES-GCM based symmetric cipher.
case secretV2 != "":
key, err := secret.ParseKey([]byte(secretV2))
if err != nil {
return nil, trace.Wrap(err)
}
ciphertext, err = key.Seal(out)
if err != nil {
return nil, trace.Wrap(err)
}
// NaCl based symmetric cipher (legacy).
case secretV1 != "":
// If FIPS mode was requested, make sure older clients that use NaCl get rejected.
if response.FIPS {
return nil, trace.BadParameter("non-FIPS compliant encryption: NaCl, check " +
"that tsh release was downloaded from https://dashboard.gravitational.com")
}
secretKeyBytes, err := lemma_secret.EncodedStringToKey(secretV1)
if err != nil {
return nil, trace.BadParameter("bad secret")
}
encryptor, err := lemma_secret.New(&lemma_secret.Config{KeyBytes: secretKeyBytes})
if err != nil {
return nil, trace.Wrap(err)
}
sealedBytes, err := encryptor.Seal(out)
if err != nil {
return nil, trace.Wrap(err)
}
ciphertext, err = json.Marshal(sealedBytes)
if err != nil {
return nil, trace.Wrap(err)
}
default:
return nil, trace.BadParameter("missing secret")
}
// Place ciphertext into the response body.
values.Set("response", string(ciphertext))
u.RawQuery = values.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"`
}
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()),
}, nil
}
// createWebSession creates a new web session based on user, pass and 2nd factor token
//
// POST /v1/webapi/sessions/web
//
// {"user": "alex", "pass": "abc123", "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")
}
// Block and wait a few seconds for the session that was created to show up
// in the cache. If this request is not blocked here, it can get stuck in a
// racy session creation loop.
err = h.waitForWebSession(r.Context(), types.GetWebSessionRequest{
User: req.User,
SessionID: webSession.GetName(),
})
if err != nil {
return nil, trace.Wrap(err)
}
if err := SetSessionCookie(w, req.User, webSession.GetName()); err != nil {
return nil, trace.Wrap(err)
}
ctx, err := h.auth.newSessionContext(r.Context(), req.User, webSession.GetName())
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) *auth.ForwardedClientMetadata {
// multiplexer handles extracting real client IP using PROXY protocol where
// available, so we can omit checking X-Forwarded-For.
return &auth.ForwardedClientMetadata{
UserAgent: r.UserAgent(),
RemoteAddr: r.RemoteAddr,
}
}
// deleteWebSession is called to sign out user
//
// DELETE /v1/webapi/sessions/:sid
//
// Response:
//
// {"message": "ok"}
func (h *Handler) deleteWebSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) {
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)
}
ClearSession(w)
return nil
}
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(newSession)
if err != nil {
return nil, trace.Wrap(err)
}
if err := SetSessionCookie(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 *wanlib.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: wanlib.CredentialCreationResponseToProto(req.WebauthnCreationResponse),
},
}
case req.SecondFactorToken != "":
protoReq.NewMFARegisterResponse = &proto.MFARegisterResponse{Response: &proto.MFARegisterResponse_TOTP{
TOTP: &proto.TOTPRegisterResponse{Code: req.SecondFactorToken},
}}
}
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.newSessionContext(r.Context(), sess.GetUser(), sess.GetName())
if err != nil {
return nil, trace.Wrap(err)
}
err = h.trySettingConnectorNameToPasswordless(r.Context(), ctx, req)
if err != nil {
h.log.WithError(err).Error("Failed to set passwordless as connector name.")
}
if err := SetSessionCookie(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
}
// trySettingConnectorNameToPasswordless sets cluster_auth_preference connectorName to `passwordless` when the first cloud user chooses passwordless as the authentication method.
// This simplifies UX for cloud users, as they will not need to select a passwordless connector when logging in.
func (h *Handler) trySettingConnectorNameToPasswordless(ctx context.Context, sessCtx *SessionContext, req changeUserAuthenticationRequest) error {
// We use the presence of a WebAuthn response, along with the absence of a
// password, as a proxy to determine that a passwordless registration took
// place, as it is not possible to infer that just from the WebAuthn response.
isPasswordlessRegistration := req.WebauthnCreationResponse != nil && len(req.Password) == 0
if !isPasswordlessRegistration {
return nil
}
if !h.ClusterFeatures.GetCloud() {
return nil
}
authPreference, err := sessCtx.cfg.RootClient.GetAuthPreference(ctx)
if err != nil {
return nil
}
if connector := authPreference.GetConnectorName(); connector != "" && connector != constants.LocalConnector {
return nil
}
users, err := h.cfg.ProxyClient.GetUsers(false)
if err != nil {
return trace.Wrap(err)
}
if len(users) != 1 {
return nil
}
authPreference.SetConnectorName(constants.PasswordlessConnector)
err = sessCtx.cfg.RootClient.SetAuthPreference(ctx, authPreference)
return trace.Wrap(err)
}
// 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 auth.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(),
auth.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": "abc123"}
// {"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 {
return nil, trace.AccessDenied("invalid credentials")
}
return client.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)
if err != nil {
// 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")
}
// Fetch user from session, user is empty for passwordless requests.
user := session.GetUser()
if err := SetSessionCookie(w, user, session.GetName()); err != nil {
return nil, trace.Wrap(err)
}
ctx, err := h.auth.newSessionContext(r.Context(), user, session.GetName())
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()
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 reversetunnel.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
}
// 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 reversetunnel.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)
}
resp, err := listResources(clt, r, types.KindNode)
if err != nil {
return nil, trace.Wrap(err)
}
servers, err := types.ResourcesWithLabels(resp.Resources).AsServers()
if err != nil {
return nil, trace.Wrap(err)
}
accessChecker, err := sctx.GetUserAccessChecker()
if err != nil {
return nil, trace.Wrap(err)
}
uiServers, err := ui.MakeServers(site.GetName(), servers, accessChecker.Roles())
if err != nil {
return nil, trace.Wrap(err)
}
return listResourcesGetResponse{
Items: uiServers,
StartKey: resp.NextKey,
TotalCount: resp.TotalCount,
}, nil
}
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 reversetunnel.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
}
// createIdentityContext creates a srv.IdentityContext from the ssh cert of the user
// stored within the SessionContext.
func createIdentityContext(login string, sessionCtx *SessionContext) (srv.IdentityContext, error) {
accessChecker, err := sessionCtx.GetUserAccessChecker()
if err != nil {
return srv.IdentityContext{}, trace.Wrap(err)
}
sshCert, err := sessionCtx.GetSSHCertificate()
if err != nil {
return srv.IdentityContext{}, trace.Wrap(err)
}
unmappedRoles, err := services.ExtractRolesFromCert(sshCert)
if err != nil {
return srv.IdentityContext{}, trace.Wrap(err)
}
accessRequestIDs, err := srv.ParseAccessRequestIDs(sshCert.Extensions[teleport.CertExtensionTeleportActiveRequests])
if err != nil {
return srv.IdentityContext{}, trace.Wrap(err)
}
return srv.IdentityContext{
AccessChecker: accessChecker,
TeleportUser: sessionCtx.GetUser(),
Login: login,
Certificate: sshCert,
UnmappedRoles: unmappedRoles,
ActiveRequests: accessRequestIDs,
Impersonator: sshCert.Extensions[teleport.CertExtensionImpersonator],
}, nil
}
// 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 reversetunnel.RemoteSite,
) (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)
}
identity, err := createIdentityContext(req.Login, sessionCtx)
if err != nil {
return nil, trace.Wrap(err)
}
ctx, err := h.cfg.SessionControl.AcquireSessionContext(r.Context(), identity, h.cfg.ProxyWebAddr.Addr, r.RemoteAddr)
if err != nil {
return nil, trace.Wrap(err)
}
var sessionData session.Session
var displayLogin string
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(ctx, clt, req, clusterName, sessionCtx.cfg.User)
if err != nil {
h.log.WithError(err).Debug("Unable to generate new ssh session.")
return nil, trace.Wrap(err)
}
} else {
sessionData, displayLogin, err = h.fetchExistingSession(ctx, clt, req, clusterName)
if err != nil {
return nil, trace.Wrap(err)
}
}
// 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())
authAccessPoint, err := site.CachingAccessPoint()
if err != nil {
h.log.WithError(err).Debug("Unable to get auth access point.")
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)
}
terminalConfig := TerminalHandlerConfig{
Term: req.Term,
SessionCtx: sessionCtx,
AuthProvider: clt,
DisplayLogin: displayLogin,
SessionData: sessionData,
KeepAliveInterval: netConfig.GetKeepAliveInterval(),
ProxyHostPort: h.ProxyHostPort(),
InteractiveCommand: req.InteractiveCommand,
Router: h.cfg.Router,
TracerProvider: h.cfg.TracerProvider,
ParticipantMode: req.ParticipantMode,
PROXYSigner: h.cfg.PROXYSigner,
}
term, err := NewTerminal(ctx, terminalConfig)
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) generateSession(ctx context.Context, clt auth.ClientI, req *TerminalRequest, clusterName string, owner string) (session.Session, error) {
var (
id string
host string
port int
)
h.log.Infof("Generating new session for %s\n", clusterName)
if _, err := uuid.Parse(req.Server); err != nil {
// The requested server is either a hostname or an address. Get all
// servers that may fuzzily match by populating SearchKeywords
resources, err := apiclient.GetResourcesWithFilters(ctx, clt, proto.ListResourcesRequest{
ResourceType: types.KindNode,
Namespace: apidefaults.Namespace,
SearchKeywords: []string{req.Server},
})
if err != nil {
return session.Session{}, trace.Wrap(err)
}
if len(resources) == 0 {
// If we didn't find the resource set host and port,
// so we can try direct dial.
host, port, err = serverHostPort(req.Server)
if err != nil {
return session.Session{}, trace.Wrap(err)
}
id = host
}
matches := 0
for _, resource := range resources {
server, ok := resource.(types.Server)
if !ok {
return session.Session{}, trace.BadParameter("expected types.Server, got: %T", resource)
}
// match by hostname
if server.GetHostname() == req.Server {
if matches > 0 {
matches++
continue
}
host = server.GetHostname()
id = server.GetName()
port = 0
matches++
continue
}
// exact match by address
if server.GetAddr() == req.Server {
if matches > 0 {
matches++
continue
}
host = req.Server
id = server.GetName()
port = 0
matches++
continue
}
}
// there was either at least one partial match or multiple
// exact matches on the server. connect with the resolved
// host and port of the requested server.
if matches > 1 || host == "" && id == "" {
host, port, err = serverHostPort(req.Server)
if err != nil {
return session.Session{}, trace.Wrap(err)
}
id = req.Server
}
} else {
// Even though the UUID was provided and can be dialed directly, the UI
// requires the hostname to populate the title of the session window.
// Looking the node up directly by UUID is the most efficient we can be until
// the UI is modified to remember the hostname when the connect button is
// used to establish a session.
server, err := clt.GetNode(ctx, apidefaults.Namespace, req.Server)
if err != nil {
return session.Session{}, trace.Wrap(err)
}
host = server.GetHostname()
port = 0
id = req.Server
}
return session.Session{
Login: req.Login,
ServerID: id,
ClusterName: clusterName,
ServerHostname: host,
ServerHostPort: port,
ID: session.NewID(),
Created: time.Now().UTC(),
LastActive: time.Now().UTC(),
Namespace: apidefaults.Namespace,
Owner: owner,
}, nil
}
func (h *Handler) fetchExistingSession(ctx context.Context, clt auth.ClientI, req *TerminalRequest, siteName string) (session.Session, string, error) {
sessionID, err := session.ParseID(req.SessionID.String())
if err != nil {
return session.Session{}, "", 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{}, "", trace.Wrap(err)
}
if tracker.GetSessionKind() != types.SSHSessionKind || tracker.GetState() == types.SessionState_SessionStateTerminated {
return session.Session{}, "", 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
// Using the Login above will then display `-teleport-internal-join` as the
// username that the user is logging in as, so we need to instead show the
// session username in the UI.
displayLogin := tracker.GetLogin()
return sessionData, displayLogin, nil
}
type siteSessionGenerateReq struct {
Session session.Session `json:"session"`
}
type siteSessionGenerateResponse struct {
Session session.Session `json:"session"`
}
// siteSessionCreate generates a new site session that can be used by UI
// The ServerID from request can be in the form of hostname, uuid, or ip address.
func (h *Handler) siteSessionGenerate(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
clt, err := sctx.GetUserClient(r.Context(), site)
if err != nil {
return nil, trace.Wrap(err)
}
var req *siteSessionGenerateReq
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}
namespace := apidefaults.Namespace
if req.Session.ServerID != "" {
servers, err := clt.GetNodes(r.Context(), namespace)
if err != nil {
return nil, trace.Wrap(err)
}
hostname, _, err := resolveServerHostPort(req.Session.ServerID, servers)
if err != nil {
return nil, trace.Wrap(err)
}
req.Session.Kind = types.SSHSessionKind
req.Session.ServerHostname = hostname
}
req.Session.ID = session.NewID()
req.Session.Created = time.Now().UTC()
req.Session.LastActive = time.Now().UTC()
req.Session.Namespace = namespace
return siteSessionGenerateResponse{Session: req.Session}, nil
}
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
})
}
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(),
DatabaseName: tracker.GetDatabaseName(),
Owner: tracker.GetHostUser(),
}
}
// siteSessionsGet gets the list of site sessions filtered by creation time
// and whether they're active or not
//
// GET /v1/webapi/sites/:site/namespaces/:namespace/sessions
//
// Response body:
//
// {"sessions": [{"id": "sid", "terminal_params": {"w": 100, "h": 100}, "parties": [], "login": "bob"}, ...] }
func (h *Handler) siteSessionsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnel.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)
}
var policySets []*types.SessionTrackerPolicySet
for _, role := range userRoles {
policySet := role.GetSessionPolicySet()
policySets = append(policySets, &policySet)
}
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(policySets, types.SSHSessionKind, session.Owner)
participantModes := accessEvaluator.CanJoin(accessContext)
sessions = append(sessions, siteSessionsGetResponseSession{Session: session, ParticipantModes: participantModes})
}
}
return siteSessionsGetResponse{Sessions: sessions}, nil
}
// siteSessionGet gets the list of site session by id
//
// GET /v1/webapi/sites/:site/namespaces/:namespace/sessions/:sid
//
// Response body:
//
// {"session": {"id": "sid", "terminal_params": {"w": 100, "h": 100}, "parties": [], "login": "bob"}}
func (h *Handler) siteSessionGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) {
sessionID, err := session.ParseID(p.ByName("sid"))
if err != nil {
return nil, trace.Wrap(err)
}
h.log.Infof("web.getSession(%v)", sessionID)
clt, err := sctx.GetUserClient(r.Context(), site)
if err != nil {
return nil, trace.Wrap(err)
}
tracker, err := clt.GetSessionTracker(r.Context(), string(*sessionID))
if err != nil {
return nil, trace.Wrap(err)
}
if tracker.GetSessionKind() != types.SSHSessionKind || tracker.GetState() == types.SessionState_SessionStateTerminated {
return nil, trace.NotFound("session %v not found", sessionID)
}
return trackerToLegacySession(tracker, site.GetName()), 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 reversetunnel.RemoteSite) (interface{}, error) {
values := r.URL.Query()
var eventTypes []string
if include := values.Get("include"); include != "" {
eventTypes = strings.Split(include, ",")
}
searchEvents := func(clt auth.ClientI, from, to time.Time, limit int, order types.EventOrder, startKey string) ([]apievents.AuditEvent, string, error) {
return clt.SearchEvents(from, to, apidefaults.Namespace, eventTypes, limit, order, 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 reversetunnel.RemoteSite) (interface{}, error) {
searchSessionEvents := func(clt auth.ClientI, from, to time.Time, limit int, order types.EventOrder, startKey string) ([]apievents.AuditEvent, string, error) {
return clt.SearchSessionEvents(from, to, limit, order, startKey, nil, "")
}
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 reversetunnel.RemoteSite, values url.Values, searchEvents func(clt auth.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
query := r.URL.Query()
offset, _ := strconv.Atoi(query.Get("offset"))
if err != nil {
onError(trace.Wrap(err))
return
}
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 reversetunnel.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
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)
}
authReq := auth.AuthenticateUserRequest{
Username: req.User,
PublicKey: req.PubKey,
ClientMetadata: clientMetaFromReq(r),
}
switch cap.GetSecondFactor() {
case constants.SecondFactorOff:
authReq.Pass = &auth.PassCreds{
Password: []byte(req.Password),
}
case constants.SecondFactorOTP, constants.SecondFactorOn, constants.SecondFactorOptional:
authReq.OTP = &auth.OTPCreds{
Password: []byte(req.Password),
Token: req.OTPToken,
}
case constants.SecondFactorWebauthn:
// WebAuthn only supports this endpoint for headless login.
authReq.HeadlessAuthenticationID = req.HeadlessAuthenticationID
default:
return nil, trace.AccessDenied("unsupported second factor type: %q", cap.GetSecondFactor())
}
loginResp, err := authClient.AuthenticateSSHUser(r.Context(), auth.AuthenticateSSHRequest{
AuthenticateUserRequest: authReq,
CompatibilityMode: req.Compatibility,
TTL: req.TTL,
RouteToCluster: req.RouteToCluster,
KubernetesCluster: req.KubernetesCluster,
AttestationStatement: req.AttestationStatement,
})
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 auth.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 reversetunnel.RemoteSite) (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)
})
}
// 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, reversetunnel.RemoteSite, error) {
sctx, err := h.AuthenticateRequest(w, r, true)
if err != nil {
h.log.WithError(err).Warn("Failed to authenticate.")
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) (reversetunnel.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) (reversetunnel.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) (auth.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) (auth.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 reversetunnel.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()
logger := h.log.WithField("request", fmt.Sprintf("%v %v", r.Method, r.URL.Path))
creds, err := roundtrip.ParseAuthHeaders(r)
if err != nil {
logger.WithError(err).Warn("No auth headers.")
return nil, trace.AccessDenied("need auth")
}
token, err := h.consumeTokenForAPICall(ctx, creds.Password)
if err != nil {
h.log.WithError(err).Warn("Failed to authenticate.")
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 (h *Handler) consumeTokenForAPICall(ctx context.Context, tokenName string) (types.ProvisionToken, error) {
token, err := h.GetProxyClient().GetToken(ctx, tokenName)
if err != nil {
return nil, trace.Wrap(err)
}
if err := h.GetProxyClient().DeleteToken(ctx, token.GetName()); err != nil {
return nil, trace.Wrap(err)
}
return token, nil
}
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) {
app.SetRedirectPageHeaders(w.Header(), "")
redirectURL := fn(w, r, p)
if !isValidRedirectURL(redirectURL) {
redirectURL = client.LoginFailedRedirectURL
}
err := metaRedirectTemplate.Execute(w, redirectURL)
if err != nil {
h.log.WithError(err).Warn("Failed to execute template.")
}
}
}
// 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)
})
}
// WithLimiter adds IP-based rate limiting to fn.
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)
})
}
// 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) {
remote, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return nil, trace.Wrap(err)
}
err = h.limiter.RegisterRequest(remote, nil /* customRate */)
// MaxRateError doesn't play well with errors.Is, hence the cast.
if _, ok := err.(*ratelimit.MaxRateError); ok {
return nil, trace.LimitExceeded(err.Error())
}
if err != nil {
return nil, trace.Wrap(err)
}
return fn(w, r, p)
}
}
// 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) {
const missingCookieMsg = "missing session cookie"
logger := h.log.WithField("request", fmt.Sprintf("%v %v", r.Method, r.URL.Path))
cookie, err := r.Cookie(CookieName)
if err != nil || (cookie != nil && cookie.Value == "") {
if err != nil {
logger.Warn(err)
}
return nil, trace.AccessDenied(missingCookieMsg)
}
decodedCookie, err := DecodeCookie(cookie.Value)
if err != nil {
logger.WithError(err).Warn("Failed to decode cookie.")
return nil, trace.AccessDenied("failed to decode cookie")
}
ctx, err := h.auth.validateSession(r.Context(), decodedCookie.User, decodedCookie.SID)
if err != nil {
logger.WithError(err).Warn("Invalid session.")
ClearSession(w)
return nil, trace.AccessDenied("need auth")
}
if checkBearerToken {
creds, err := roundtrip.ParseAuthHeaders(r)
if err != nil {
logger.WithError(err).Warn("No auth headers.")
return nil, trace.AccessDenied("need auth")
}
if err := ctx.validateBearerToken(r.Context(), creds.Password); err != nil {
logger.WithError(err).Warn("Request failed: bad bearer token.")
return nil, trace.AccessDenied("bad bearer token")
}
}
return ctx, nil
}
// ProxyWithRoles returns a reverse tunnel proxy verifying the permissions
// of the given user.
func (h *Handler) ProxyWithRoles(ctx *SessionContext) (reversetunnel.Tunnel, error) {
accessChecker, err := ctx.GetUserAccessChecker()
if err != nil {
h.log.WithError(err).Warn("Failed to get client roles.")
return nil, trace.Wrap(err)
}
return reversetunnel.NewTunnelWithRoles(h.cfg.Proxy, 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 := SetSessionCookie(w, response.Username, response.SessionName); err != nil {
return trace.Wrap(err)
}
parsedURL, err := url.Parse(response.ClientRedirectURL)
if err != nil {
return trace.Wrap(err)
}
response.ClientRedirectURL = parsedURL.RequestURI()
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>
func (h *Handler) authExportPublic(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
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)
}
// isDashboard returns a bool indicating if the cluster is a
// dashboard cluster.
// Dashboard is a cluster running on cloud infrastructure that
// isn't a Teleport Cloud cluster
func isDashboard(features proto.Features) bool {
// TODO(matheus): for now, we assume dashboard based on
// the presence of recovery codes, which are never enabled
// in OSS or self-hosted Teleport.
return !features.GetCloud() && features.GetRecoveryCodes()
}