From f01bc20d444b21d78fc8686b32d345713fa16218 Mon Sep 17 00:00:00 2001 From: Jens L Date: Thu, 29 Jul 2021 11:30:30 +0200 Subject: [PATCH] Embedded outpost (#1193) * api: allow API requests as managed outpost's account when using secret_key Signed-off-by: Jens Langhammer * root: load secret key from env Signed-off-by: Jens Langhammer * outposts: make listener IP configurable Signed-off-by: Jens Langhammer * outpost/proxy: run outpost in background and pass requests conditionally Signed-off-by: Jens Langhammer * outpost: unify branding to embedded Signed-off-by: Jens Langhammer * web/admin: fix embedded outpost not being editable Signed-off-by: Jens Langhammer * web: fix mismatched host detection Signed-off-by: Jens Langhammer * tests/e2e: fix LDAP test not including user for embedded outpost Signed-off-by: Jens Langhammer * tests/e2e: fix user matching Signed-off-by: Jens Langhammer * api: add tests for secret_key auth Signed-off-by: Jens Langhammer * root: load environment variables using github.com/Netflix/go-env Signed-off-by: Jens Langhammer --- authentik/api/authentication.py | 32 ++++++++-- authentik/api/tests/test_auth.py | 27 +++++--- authentik/core/channels.py | 10 +-- authentik/outposts/apps.py | 1 + authentik/outposts/managed.py | 7 ++- authentik/outposts/tests/test_sa.py | 8 ++- cmd/server/main.go | 77 +++++++++++++---------- go.mod | 1 + go.sum | 2 + internal/common/global.go | 4 +- internal/config/config.go | 18 +++++- internal/config/struct.go | 14 ++--- internal/outpost/ak/api.go | 20 ++++-- internal/outpost/proxy/cookies.go | 3 +- internal/outpost/proxy/middleware.go | 3 +- internal/outpost/proxy/proxy.go | 3 +- internal/outpost/proxy/server.go | 26 +++++++- internal/outpost/proxy/server_https.go | 18 +----- internal/outpost/proxy/utils.go | 16 ----- internal/utils/web/host.go | 15 +++++ internal/web/middleware_log.go | 4 +- internal/web/web.go | 3 + internal/web/web_proxy.go | 23 ++++++- tests/e2e/test_provider_ldap.py | 29 +++++++++ tests/e2e/test_source_saml.py | 3 + tests/e2e/utils.py | 1 + web/src/pages/outposts/OutpostHealth.ts | 8 ++- web/src/pages/outposts/OutpostListPage.ts | 27 ++++++++ 28 files changed, 285 insertions(+), 118 deletions(-) create mode 100644 internal/utils/web/host.go diff --git a/authentik/api/authentication.py b/authentik/api/authentication.py index ee423bf59..098de8e3e 100644 --- a/authentik/api/authentication.py +++ b/authentik/api/authentication.py @@ -3,18 +3,20 @@ from base64 import b64decode from binascii import Error from typing import Any, Optional, Union +from django.conf import settings from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.exceptions import AuthenticationFailed from rest_framework.request import Request from structlog.stdlib import get_logger from authentik.core.models import Token, TokenIntents, User +from authentik.outposts.models import Outpost LOGGER = get_logger() # pylint: disable=too-many-return-statements -def token_from_header(raw_header: bytes) -> Optional[Token]: +def bearer_auth(raw_header: bytes) -> Optional[User]: """raw_header in the Format of `Bearer dGVzdDp0ZXN0`""" auth_credentials = raw_header.decode() if auth_credentials == "" or " " not in auth_credentials: @@ -38,8 +40,26 @@ def token_from_header(raw_header: bytes) -> Optional[Token]: raise AuthenticationFailed("Malformed header") tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API) if not tokens.exists(): - raise AuthenticationFailed("Token invalid/expired") - return tokens.first() + LOGGER.info("Authenticating via secret_key") + user = token_secret_key(password) + if not user: + raise AuthenticationFailed("Token invalid/expired") + return user + return tokens.first().user + + +def token_secret_key(value: str) -> Optional[User]: + """Check if the token is the secret key + and return the service account for the managed outpost""" + from authentik.outposts.managed import MANAGED_OUTPOST + + if value != settings.SECRET_KEY: + return None + outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) + if not outposts: + return None + outpost = outposts.first() + return outpost.user class TokenAuthentication(BaseAuthentication): @@ -49,9 +69,9 @@ class TokenAuthentication(BaseAuthentication): """Token-based authentication using HTTP Bearer authentication""" auth = get_authorization_header(request) - token = token_from_header(auth) + user = bearer_auth(auth) # None is only returned when the header isn't set. - if not token: + if not user: return None - return (token.user, None) # pragma: no cover + return (user, None) # pragma: no cover diff --git a/authentik/api/tests/test_auth.py b/authentik/api/tests/test_auth.py index b7ef750cc..1f22059fc 100644 --- a/authentik/api/tests/test_auth.py +++ b/authentik/api/tests/test_auth.py @@ -1,12 +1,14 @@ """Test API Authentication""" from base64 import b64encode +from django.conf import settings from django.test import TestCase from guardian.shortcuts import get_anonymous_user from rest_framework.exceptions import AuthenticationFailed -from authentik.api.authentication import token_from_header -from authentik.core.models import Token, TokenIntents +from authentik.api.authentication import bearer_auth +from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents +from authentik.outposts.managed import OutpostManager class TestAPIAuth(TestCase): @@ -18,32 +20,41 @@ class TestAPIAuth(TestCase): intent=TokenIntents.INTENT_API, user=get_anonymous_user() ) auth = b64encode(f":{token.key}".encode()).decode() - self.assertEqual(token_from_header(f"Basic {auth}".encode()), token) + self.assertEqual(bearer_auth(f"Basic {auth}".encode()), token.user) def test_valid_bearer(self): """Test valid token""" token = Token.objects.create( intent=TokenIntents.INTENT_API, user=get_anonymous_user() ) - self.assertEqual(token_from_header(f"Bearer {token.key}".encode()), token) + self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user) def test_invalid_type(self): """Test invalid type""" with self.assertRaises(AuthenticationFailed): - token_from_header("foo bar".encode()) + bearer_auth("foo bar".encode()) def test_invalid_decode(self): """Test invalid bas64""" with self.assertRaises(AuthenticationFailed): - token_from_header("Basic bar".encode()) + bearer_auth("Basic bar".encode()) def test_invalid_empty_password(self): """Test invalid with empty password""" with self.assertRaises(AuthenticationFailed): - token_from_header("Basic :".encode()) + bearer_auth("Basic :".encode()) def test_invalid_no_token(self): """Test invalid with no token""" with self.assertRaises(AuthenticationFailed): auth = b64encode(":abc".encode()).decode() - self.assertIsNone(token_from_header(f"Basic :{auth}".encode())) + self.assertIsNone(bearer_auth(f"Basic :{auth}".encode())) + + def test_managed_outpost(self): + """Test managed outpost""" + with self.assertRaises(AuthenticationFailed): + user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) + + OutpostManager().run() + user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) + self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True) diff --git a/authentik/core/channels.py b/authentik/core/channels.py index a081ec985..00f213efc 100644 --- a/authentik/core/channels.py +++ b/authentik/core/channels.py @@ -4,7 +4,7 @@ from channels.generic.websocket import JsonWebsocketConsumer from rest_framework.exceptions import AuthenticationFailed from structlog.stdlib import get_logger -from authentik.api.authentication import token_from_header +from authentik.api.authentication import bearer_auth from authentik.core.models import User LOGGER = get_logger() @@ -24,12 +24,12 @@ class AuthJsonConsumer(JsonWebsocketConsumer): raw_header = headers[b"authorization"] try: - token = token_from_header(raw_header) - # token is only None when no header was given, in which case we deny too - if not token: + user = bearer_auth(raw_header) + # user is only None when no header was given, in which case we deny too + if not user: raise DenyConnection() except AuthenticationFailed as exc: LOGGER.warning("Failed to authenticate", exc=exc) raise DenyConnection() - self.user = token.user + self.user = user diff --git a/authentik/outposts/apps.py b/authentik/outposts/apps.py index ac93c2ab6..0ee7aa9d1 100644 --- a/authentik/outposts/apps.py +++ b/authentik/outposts/apps.py @@ -17,6 +17,7 @@ class AuthentikOutpostConfig(AppConfig): def ready(self): import_module("authentik.outposts.signals") + import_module("authentik.outposts.managed") try: from authentik.outposts.tasks import outpost_local_connection diff --git a/authentik/outposts/managed.py b/authentik/outposts/managed.py index 012420643..5c66646e3 100644 --- a/authentik/outposts/managed.py +++ b/authentik/outposts/managed.py @@ -2,6 +2,8 @@ from authentik.managed.manager import EnsureExists, ObjectManager from authentik.outposts.models import Outpost, OutpostType +MANAGED_OUTPOST = "goauthentik.io/outposts/embedded" + class OutpostManager(ObjectManager): """Outpost managed objects""" @@ -10,9 +12,8 @@ class OutpostManager(ObjectManager): return [ EnsureExists( Outpost, - "goauthentik.io/outposts/inbuilt", - name="authentik Bundeled Outpost", - object_field="name", + MANAGED_OUTPOST, + name="authentik Embedded Outpost", type=OutpostType.PROXY, ), ] diff --git a/authentik/outposts/tests/test_sa.py b/authentik/outposts/tests/test_sa.py index 9491da0ba..761cbabe0 100644 --- a/authentik/outposts/tests/test_sa.py +++ b/authentik/outposts/tests/test_sa.py @@ -1,4 +1,6 @@ """outpost tests""" +from django.apps import apps +from django.contrib.auth.management import create_permissions from django.test import TestCase from guardian.models import UserObjectPermission @@ -11,6 +13,10 @@ from authentik.providers.proxy.models import ProxyProvider class OutpostTests(TestCase): """Outpost Tests""" + def setUp(self) -> None: + create_permissions(apps.get_app_config("authentik_outposts")) + return super().setUp() + def test_service_account_permissions(self): """Test that the service account has correct permissions""" provider: ProxyProvider = ProxyProvider.objects.create( @@ -31,7 +37,6 @@ class OutpostTests(TestCase): # We add a provider, user should only have access to outpost and provider outpost.providers.add(provider) - outpost.save() permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by( "content_type__model" ) @@ -53,7 +58,6 @@ class OutpostTests(TestCase): # Remove provider from outpost, user should only have access to outpost outpost.providers.remove(provider) - outpost.save() permissions = UserObjectPermission.objects.filter(user=outpost.user) self.assertEqual(len(permissions), 1) self.assertEqual(permissions[0].object_pk, str(outpost.pk)) diff --git a/cmd/server/main.go b/cmd/server/main.go index 5064be297..d9f5e6419 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,6 +2,8 @@ package main import ( "fmt" + "net/url" + "time" "github.com/getsentry/sentry-go" log "github.com/sirupsen/logrus" @@ -9,6 +11,8 @@ import ( "goauthentik.io/internal/config" "goauthentik.io/internal/constants" "goauthentik.io/internal/gounicorn" + "goauthentik.io/internal/outpost/ak" + "goauthentik.io/internal/outpost/proxy" "goauthentik.io/internal/web" ) @@ -25,6 +29,10 @@ func main() { if err != nil { log.WithError(err).Debug("failed to local config") } + err = config.FromEnv() + if err != nil { + log.WithError(err).Debug("failed to environment variables") + } config.ConfigureLogger() if config.G.ErrorReporting.Enabled { @@ -43,7 +51,7 @@ func main() { ex := common.Init() defer common.Defer() - // u, _ := url.Parse("http://localhost:8000") + u, _ := url.Parse("http://localhost:8000") g := gounicorn.NewGoUnicorn() ws := web.NewWebServer() @@ -52,7 +60,7 @@ func main() { for { go attemptStartBackend(g) ws.Start() - // go attemptProxyStart(u) + go attemptProxyStart(ws, u) <-ex running = false @@ -73,35 +81,36 @@ func attemptStartBackend(g *gounicorn.GoUnicorn) { } } -// func attemptProxyStart(u *url.URL) error { -// maxTries := 100 -// attempt := 0 -// for { -// log.WithField("logger", "authentik").Debug("attempting to init outpost") -// ac := ak.NewAPIController(*u, config.G.SecretKey) -// if ac == nil { -// attempt += 1 -// time.Sleep(1 * time.Second) -// if attempt > maxTries { -// break -// } -// continue -// } -// ac.Server = proxy.NewServer(ac) -// err := ac.Start() -// log.WithField("logger", "authentik").Debug("attempting to start outpost") -// if err != nil { -// attempt += 1 -// time.Sleep(5 * time.Second) -// if attempt > maxTries { -// break -// } -// continue -// } -// if !running { -// ac.Shutdown() -// return nil -// } -// } -// return nil -// } +func attemptProxyStart(ws *web.WebServer, u *url.URL) { + maxTries := 100 + attempt := 0 + // Sleep to wait for the app server to start + time.Sleep(30 * time.Second) + for { + log.WithField("logger", "authentik").Debug("attempting to init outpost") + ac := ak.NewAPIController(*u, config.G.SecretKey) + if ac == nil { + attempt += 1 + time.Sleep(1 * time.Second) + if attempt > maxTries { + break + } + continue + } + srv := proxy.NewServer(ac) + ws.ProxyServer = srv + ac.Server = srv + log.WithField("logger", "authentik").Debug("attempting to start outpost") + err := ac.StartBackgorundTasks() + if err != nil { + attempt += 1 + time.Sleep(15 * time.Second) + if attempt > maxTries { + break + } + continue + } else { + select {} + } + } +} diff --git a/go.mod b/go.mod index 592154340..b4cf674b6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module goauthentik.io go 1.16 require ( + github.com/Netflix/go-env v0.0.0-20210215222557-e437a7e7f9fb // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect github.com/coreos/go-oidc v2.2.1+incompatible github.com/getsentry/sentry-go v0.11.0 diff --git a/go.sum b/go.sum index 57e0f1955..1d1b525ee 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3 github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo= github.com/FZambia/sentinel v1.0.0/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI= github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= +github.com/Netflix/go-env v0.0.0-20210215222557-e437a7e7f9fb h1:w9IDEB7P1VzNcBpOG7kMpFkZp2DkyJIUt0gDx5MBhRU= +github.com/Netflix/go-env v0.0.0-20210215222557-e437a7e7f9fb/go.mod h1:9XMFaCeRyW7fC9XJOWQ+NdAv8VLG7ys7l3x4ozEGLUQ= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= diff --git a/internal/common/global.go b/internal/common/global.go index a60edf239..eae3b0fa3 100644 --- a/internal/common/global.go +++ b/internal/common/global.go @@ -17,6 +17,6 @@ func Init() chan os.Signal { } func Defer() { - defer sentry.Flush(time.Second * 5) - defer sentry.Recover() + sentry.Flush(time.Second * 5) + sentry.Recover() } diff --git a/internal/config/config.go b/internal/config/config.go index db640f8d5..8aba4f74f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,8 +2,8 @@ package config import ( "io/ioutil" - "os" + env "github.com/Netflix/go-env" "github.com/imdario/mergo" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -35,9 +35,8 @@ func LoadConfig(path string) error { if err != nil { return errors.Wrap(err, "Failed to load config file") } - rawExpanded := os.ExpandEnv(string(raw)) nc := Config{} - err = yaml.Unmarshal([]byte(rawExpanded), &nc) + err = yaml.Unmarshal(raw, &nc) if err != nil { return errors.Wrap(err, "Failed to parse YAML") } @@ -48,6 +47,19 @@ func LoadConfig(path string) error { return nil } +func FromEnv() error { + nc := Config{} + _, err := env.UnmarshalFromEnviron(&nc) + if err != nil { + return errors.Wrap(err, "failed to load environment variables") + } + if err := mergo.Merge(&G, nc, mergo.WithOverride); err != nil { + return errors.Wrap(err, "failed to overlay config") + } + log.Debug("Loaded config from environment") + return nil +} + func ConfigureLogger() { switch G.LogLevel { case "trace": diff --git a/internal/config/struct.go b/internal/config/struct.go index 984bf8b6c..7a15de7a1 100644 --- a/internal/config/struct.go +++ b/internal/config/struct.go @@ -1,18 +1,18 @@ package config type Config struct { - Debug bool `yaml:"debug"` - SecretKey string `yaml:"secret_key"` + Debug bool `yaml:"debug" env:"AUTHENTIK_DEBUG"` + SecretKey string `yaml:"secret_key" env:"AUTHENTIK_SECRET_KEY"` Web WebConfig `yaml:"web"` Paths PathsConfig `yaml:"paths"` - LogLevel string `yaml:"log_level"` + LogLevel string `yaml:"log_level" env:"AUTHENTIK_LOG_LEVEL"` ErrorReporting ErrorReportingConfig `yaml:"error_reporting"` } type WebConfig struct { Listen string `yaml:"listen"` ListenTLS string `yaml:"listen_tls"` - LoadLocalFiles bool `yaml:"load_local_files"` + LoadLocalFiles bool `yaml:"load_local_files" env:"AUTHENTIK_WEB_LOAD_LOCAL_FILES"` } type PathsConfig struct { @@ -20,7 +20,7 @@ type PathsConfig struct { } type ErrorReportingConfig struct { - Enabled bool `yaml:"enabled"` - Environment string `yaml:"environment"` - SendPII bool `yaml:"send_pii"` + Enabled bool `yaml:"enabled" env:"AUTHENTIK_ERROR_REPORTING__ENABLED"` + Environment string `yaml:"environment" env:"AUTHENTIK_ERROR_REPORTING__ENVIRONMENT"` + SendPII bool `yaml:"send_pii" env:"AUTHENTIK_ERROR_REPORTING__SEND_PII"` } diff --git a/internal/outpost/ak/api.go b/internal/outpost/ak/api.go index 26a02b16c..4578178d5 100644 --- a/internal/outpost/ak/api.go +++ b/internal/outpost/ak/api.go @@ -80,6 +80,20 @@ func NewAPIController(akURL url.URL, token string) *APIController { // Start Starts all handlers, non-blocking func (a *APIController) Start() error { + err := a.StartBackgorundTasks() + if err != nil { + return err + } + go func() { + err := a.Server.Start() + if err != nil { + panic(err) + } + }() + return nil +} + +func (a *APIController) StartBackgorundTasks() error { err := a.Server.Refresh() if err != nil { return errors.Wrap(err, "failed to run initial refresh") @@ -96,11 +110,5 @@ func (a *APIController) Start() error { a.logger.Debug("Starting Interval updater...") a.startIntervalUpdater() }() - go func() { - err := a.Server.Start() - if err != nil { - panic(err) - } - }() return nil } diff --git a/internal/outpost/proxy/cookies.go b/internal/outpost/proxy/cookies.go index 59045ef16..d611c7213 100644 --- a/internal/outpost/proxy/cookies.go +++ b/internal/outpost/proxy/cookies.go @@ -8,6 +8,7 @@ import ( sessionsapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions" "github.com/oauth2-proxy/oauth2-proxy/pkg/cookies" + "goauthentik.io/internal/utils/web" ) // MakeCSRFCookie creates a cookie for CSRF @@ -19,7 +20,7 @@ func (p *OAuthProxy) makeCookie(req *http.Request, name string, value string, ex cookieDomain := cookies.GetCookieDomain(req, p.CookieDomains) if cookieDomain != "" { - domain := getHost(req) + domain := web.GetHost(req) if h, _, err := net.SplitHostPort(domain); err == nil { domain = h } diff --git a/internal/outpost/proxy/middleware.go b/internal/outpost/proxy/middleware.go index 7c938ea77..514e6d207 100644 --- a/internal/outpost/proxy/middleware.go +++ b/internal/outpost/proxy/middleware.go @@ -9,6 +9,7 @@ import ( "time" log "github.com/sirupsen/logrus" + "goauthentik.io/internal/utils/web" ) // responseLogger is wrapper of http.ResponseWriter that keeps track of its HTTP status @@ -107,7 +108,7 @@ func (h loggingHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { duration := float64(time.Since(t)) / float64(time.Millisecond) h.logger.WithFields(log.Fields{ "host": req.RemoteAddr, - "vhost": getHost(req), + "vhost": web.GetHost(req), "request_protocol": req.Proto, "runtime": fmt.Sprintf("%0.3f", duration), "method": req.Method, diff --git a/internal/outpost/proxy/proxy.go b/internal/outpost/proxy/proxy.go index 99dbb4638..4fe7c1158 100644 --- a/internal/outpost/proxy/proxy.go +++ b/internal/outpost/proxy/proxy.go @@ -21,6 +21,7 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/pkg/upstream" "github.com/oauth2-proxy/oauth2-proxy/providers" "goauthentik.io/api" + "goauthentik.io/internal/utils/web" log "github.com/sirupsen/logrus" ) @@ -308,7 +309,7 @@ func (p *OAuthProxy) AuthenticateOnly(rw http.ResponseWriter, req *http.Request) // Optional suffix, which is appended to the URL suffix := "" if p.mode == api.PROXYMODE_FORWARD_SINGLE { - host = getHost(req) + host = web.GetHost(req) } else if p.mode == api.PROXYMODE_FORWARD_DOMAIN { host = p.ExternalHost // set the ?rd flag to the current URL we have, since we redirect diff --git a/internal/outpost/proxy/server.go b/internal/outpost/proxy/server.go index bffdc8789..27d735f0a 100644 --- a/internal/outpost/proxy/server.go +++ b/internal/outpost/proxy/server.go @@ -4,19 +4,23 @@ import ( "context" "crypto/tls" "errors" + "fmt" "net" "net/http" "strings" "time" + "github.com/pires/go-proxyproto" log "github.com/sirupsen/logrus" "goauthentik.io/internal/crypto" "goauthentik.io/internal/outpost/ak" + "goauthentik.io/internal/utils/web" ) // Server represents an HTTP server type Server struct { Handlers map[string]*providerBundle + Listen string stop chan struct{} // channel for waiting shutdown logger *log.Entry @@ -33,6 +37,7 @@ func NewServer(ac *ak.APIController) *Server { } return &Server{ Handlers: make(map[string]*providerBundle), + Listen: "0.0.0.0:%d", logger: log.WithField("logger", "authentik.outpost.proxy-http-server"), defaultCert: defaultCert, ak: ac, @@ -40,12 +45,27 @@ func NewServer(ac *ak.APIController) *Server { } } -func (s *Server) handler(w http.ResponseWriter, r *http.Request) { +// ServeHTTP constructs a net.Listener and starts handling HTTP requests +func (s *Server) ServeHTTP() { + listenAddress := fmt.Sprintf(s.Listen, 4180) + listener, err := net.Listen("tcp", listenAddress) + if err != nil { + s.logger.Fatalf("FATAL: listen (%s) failed - %s", listenAddress, err) + } + proxyListener := &proxyproto.Listener{Listener: listener} + defer proxyListener.Close() + + s.logger.Printf("listening on %s", listener.Addr()) + s.serve(proxyListener) + s.logger.Printf("closing %s", listener.Addr()) +} + +func (s *Server) Handler(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/akprox/ping" { w.WriteHeader(204) return } - host := getHost(r) + host := web.GetHost(r) handler, ok := s.Handlers[host] if !ok { // If we only have one handler, host name switching doesn't matter @@ -68,7 +88,7 @@ func (s *Server) handler(w http.ResponseWriter, r *http.Request) { } func (s *Server) serve(listener net.Listener) { - srv := &http.Server{Handler: http.HandlerFunc(s.handler)} + srv := &http.Server{Handler: http.HandlerFunc(s.Handler)} // See https://golang.org/pkg/net/http/#Server.Shutdown idleConnsClosed := make(chan struct{}) diff --git a/internal/outpost/proxy/server_https.go b/internal/outpost/proxy/server_https.go index 304baa0f2..d939bc54e 100644 --- a/internal/outpost/proxy/server_https.go +++ b/internal/outpost/proxy/server_https.go @@ -2,27 +2,13 @@ package proxy import ( "crypto/tls" + "fmt" "net" "sync" "github.com/pires/go-proxyproto" ) -// ServeHTTP constructs a net.Listener and starts handling HTTP requests -func (s *Server) ServeHTTP() { - listenAddress := "0.0.0.0:4180" - listener, err := net.Listen("tcp", listenAddress) - if err != nil { - s.logger.Fatalf("FATAL: listen (%s) failed - %s", listenAddress, err) - } - proxyListener := &proxyproto.Listener{Listener: listener} - defer proxyListener.Close() - - s.logger.Printf("listening on %s", listener.Addr()) - s.serve(proxyListener) - s.logger.Printf("closing %s", listener.Addr()) -} - func (s *Server) getCertificates(info *tls.ClientHelloInfo) (*tls.Certificate, error) { handler, ok := s.Handlers[info.ServerName] if !ok { @@ -38,7 +24,7 @@ func (s *Server) getCertificates(info *tls.ClientHelloInfo) (*tls.Certificate, e // ServeHTTPS constructs a net.Listener and starts handling HTTPS requests func (s *Server) ServeHTTPS() { - listenAddress := "0.0.0.0:4443" + listenAddress := fmt.Sprintf(s.Listen, 4443) config := &tls.Config{ MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, diff --git a/internal/outpost/proxy/utils.go b/internal/outpost/proxy/utils.go index 6fb02841f..4ddbd3980 100644 --- a/internal/outpost/proxy/utils.go +++ b/internal/outpost/proxy/utils.go @@ -1,25 +1,9 @@ package proxy import ( - "net" - "net/http" "strconv" ) -var xForwardedHost = http.CanonicalHeaderKey("X-Forwarded-Host") - -func getHost(req *http.Request) string { - host := req.Host - if req.Header.Get(xForwardedHost) != "" { - host = req.Header.Get(xForwardedHost) - } - hostOnly, _, err := net.SplitHostPort(host) - if err != nil { - return host - } - return hostOnly -} - // toString Generic to string function, currently supports actual strings and integers func toString(in interface{}) string { switch v := in.(type) { diff --git a/internal/utils/web/host.go b/internal/utils/web/host.go new file mode 100644 index 000000000..bd655ae4f --- /dev/null +++ b/internal/utils/web/host.go @@ -0,0 +1,15 @@ +package web + +import ( + "net/http" +) + +var xForwardedHost = http.CanonicalHeaderKey("X-Forwarded-Host") + +func GetHost(req *http.Request) string { + host := req.Host + if req.Header.Get(xForwardedHost) != "" { + host = req.Header.Get(xForwardedHost) + } + return host +} diff --git a/internal/web/middleware_log.go b/internal/web/middleware_log.go index 79bddc597..026514e9b 100644 --- a/internal/web/middleware_log.go +++ b/internal/web/middleware_log.go @@ -6,11 +6,12 @@ import ( "github.com/getsentry/sentry-go" log "github.com/sirupsen/logrus" + "goauthentik.io/internal/utils/web" ) func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - span := sentry.StartSpan(r.Context(), "request.logging") + span := sentry.StartSpan(r.Context(), "authentik.go.request") before := time.Now() // Call the next handler, which can be another middleware in the chain, or the final handler. next.ServeHTTP(w, r) @@ -19,6 +20,7 @@ func loggingMiddleware(next http.Handler) http.Handler { "remote": r.RemoteAddr, "method": r.Method, "took": after.Sub(before), + "host": web.GetHost(r), }).Info(r.RequestURI) span.Finish() }) diff --git a/internal/web/web.go b/internal/web/web.go index f145b1f0c..1c48b3871 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -11,6 +11,7 @@ import ( "github.com/pires/go-proxyproto" log "github.com/sirupsen/logrus" "goauthentik.io/internal/config" + "goauthentik.io/internal/outpost/proxy" ) type WebServer struct { @@ -21,6 +22,8 @@ type WebServer struct { stop chan struct{} // channel for waiting shutdown + ProxyServer *proxy.Server + m *mux.Router lh *mux.Router log *log.Entry diff --git a/internal/web/web_proxy.go b/internal/web/web_proxy.go index 84537b645..a6a51425b 100644 --- a/internal/web/web_proxy.go +++ b/internal/web/web_proxy.go @@ -1,9 +1,12 @@ package web import ( + "fmt" "net/http" "net/http/httputil" "net/url" + + "goauthentik.io/internal/utils/web" ) func (ws *WebServer) configureProxy() { @@ -23,7 +26,25 @@ func (ws *WebServer) configureProxy() { rp := &httputil.ReverseProxy{Director: director} rp.ErrorHandler = ws.proxyErrorHandler rp.ModifyResponse = ws.proxyModifyResponse - ws.m.PathPrefix("/").Handler(rp) + ws.m.PathPrefix("/akprox").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if ws.ProxyServer != nil { + ws.ProxyServer.Handler(rw, r) + return + } + ws.proxyErrorHandler(rw, r, fmt.Errorf("proxy not running")) + }) + ws.m.PathPrefix("/").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + host := web.GetHost(r) + if ws.ProxyServer != nil { + if _, ok := ws.ProxyServer.Handlers[host]; ok { + ws.log.WithField("host", host).Trace("routing to proxy outpost") + ws.ProxyServer.Handler(rw, r) + return + } + } + ws.log.WithField("host", host).Trace("routing to application server") + rp.ServeHTTP(rw, r) + }) } func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request, err error) { diff --git a/tests/e2e/test_provider_ldap.py b/tests/e2e/test_provider_ldap.py index 3c479b4e1..dbba8dcf1 100644 --- a/tests/e2e/test_provider_ldap.py +++ b/tests/e2e/test_provider_ldap.py @@ -19,6 +19,7 @@ from ldap3.core.exceptions import LDAPInvalidCredentialsResult from authentik.core.models import Application, Group, User from authentik.events.models import Event, EventAction from authentik.flows.models import Flow +from authentik.outposts.managed import MANAGED_OUTPOST from authentik.outposts.models import Outpost, OutpostType from authentik.providers.ldap.models import LDAPProvider from tests.e2e.utils import ( @@ -193,6 +194,9 @@ class TestProviderLDAP(SeleniumTestCase): }, ) ) + + embedded_account = Outpost.objects.filter(managed=MANAGED_OUTPOST).first().user + _connection.search( "ou=users,dc=ldap,dc=goauthentik,dc=io", "(objectClass=user)", @@ -232,6 +236,31 @@ class TestProviderLDAP(SeleniumTestCase): }, "type": "searchResEntry", }, + { + "dn": f"cn={embedded_account.username},ou=users,dc=ldap,dc=goauthentik,dc=io", + "attributes": { + "cn": [embedded_account.username], + "uid": [embedded_account.uid], + "name": [""], + "displayName": [""], + "mail": [""], + "objectClass": [ + "user", + "organizationalPerson", + "goauthentik.io/ldap/user", + ], + "uidNumber": [str(2000 + embedded_account.pk)], + "gidNumber": [str(2000 + embedded_account.pk)], + "memberOf": [], + "accountStatus": ["true"], + "superuser": ["false"], + "goauthentik.io/ldap/active": ["true"], + "goauthentik.io/ldap/superuser": ["false"], + "goauthentik.io/user/override-ips": ["true"], + "goauthentik.io/user/service-account": ["true"], + }, + "type": "searchResEntry", + }, { "dn": f"cn={USER().username},ou=users,dc=ldap,dc=goauthentik,dc=io", "attributes": { diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py index ad8ee1930..314b0272e 100644 --- a/tests/e2e/test_source_saml.py +++ b/tests/e2e/test_source_saml.py @@ -164,6 +164,7 @@ class TestSourceSAML(SeleniumTestCase): self.assert_user( User.objects.exclude(username="akadmin") + .exclude(username__startswith="ak-outpost") .exclude(pk=get_anonymous_user().pk) .first() ) @@ -249,6 +250,7 @@ class TestSourceSAML(SeleniumTestCase): self.assert_user( User.objects.exclude(username="akadmin") + .exclude(username__startswith="ak-outpost") .exclude(pk=get_anonymous_user().pk) .first() ) @@ -321,6 +323,7 @@ class TestSourceSAML(SeleniumTestCase): self.assert_user( User.objects.exclude(username="akadmin") + .exclude(username__startswith="ak-outpost") .exclude(pk=get_anonymous_user().pk) .first() ) diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index 20193d464..5ace1eab0 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -48,6 +48,7 @@ class SeleniumTestCase(StaticLiveServerTestCase): def setUp(self): super().setUp() + self.maxDiff = None self.wait_timeout = 60 self.driver = self._get_driver() self.driver.maximize_window() diff --git a/web/src/pages/outposts/OutpostHealth.ts b/web/src/pages/outposts/OutpostHealth.ts index 8c3395a6f..3c001ebaf 100644 --- a/web/src/pages/outposts/OutpostHealth.ts +++ b/web/src/pages/outposts/OutpostHealth.ts @@ -17,6 +17,9 @@ export class OutpostHealthElement extends LitElement { @property({attribute: false}) outpostHealth?: OutpostHealth[]; + @property({attribute: false}) + showVersion = true; + static get styles(): CSSResult[] { return [PFBase, AKGlobal]; } @@ -56,12 +59,13 @@ export class OutpostHealthElement extends LitElement {
  • -
  • + ${this.showVersion ? + html`
  • ${h.versionOutdated ? html`` : html``} -
  • + ` : html``} `; })}`; diff --git a/web/src/pages/outposts/OutpostListPage.ts b/web/src/pages/outposts/OutpostListPage.ts index 648e0c0fe..db209e1dc 100644 --- a/web/src/pages/outposts/OutpostListPage.ts +++ b/web/src/pages/outposts/OutpostListPage.ts @@ -53,6 +53,9 @@ export class OutpostListPage extends TablePage { order = "name"; row(item: Outpost): TemplateResult[] { + if (item.managed === "goauthentik.io/outposts/embedded") { + return this.rowInbuilt(item); + } return [ html`${item.name}`, html`
      ${item.providersObj?.map((p) => { @@ -99,6 +102,30 @@ export class OutpostListPage extends TablePage { ]; } + rowInbuilt(item: Outpost): TemplateResult[] { + return [ + html`${item.name}`, + html`
        ${item.providersObj?.map((p) => { + return html`
      • ${p.name}
      • `; + })}
      `, + html`-`, + html``, + html` + + ${t`Update`} + + + ${t`Update Outpost`} + + + + + `, + ]; + } + renderToolbar(): TemplateResult { return html`