Embedded outpost (#1193)

* api: allow API requests as managed outpost's account when using secret_key

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* root: load secret key from env

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* outposts: make listener IP configurable

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* outpost/proxy: run outpost in background and pass requests conditionally

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* outpost: unify branding to embedded

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* web/admin: fix embedded outpost not being editable

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* web: fix mismatched host detection

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* tests/e2e: fix LDAP test not including user for embedded outpost

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* tests/e2e: fix user matching

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* api: add tests for secret_key auth

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* root: load environment variables using github.com/Netflix/go-env

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
master
Jens L 2021-07-29 11:30:30 +02:00 committed by GitHub
parent 1b03aae7aa
commit f01bc20d44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 285 additions and 118 deletions

View File

@ -3,18 +3,20 @@ from base64 import b64decode
from binascii import Error from binascii import Error
from typing import Any, Optional, Union from typing import Any, Optional, Union
from django.conf import settings
from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request from rest_framework.request import Request
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Token, TokenIntents, User from authentik.core.models import Token, TokenIntents, User
from authentik.outposts.models import Outpost
LOGGER = get_logger() LOGGER = get_logger()
# pylint: disable=too-many-return-statements # 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`""" """raw_header in the Format of `Bearer dGVzdDp0ZXN0`"""
auth_credentials = raw_header.decode() auth_credentials = raw_header.decode()
if auth_credentials == "" or " " not in auth_credentials: 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") raise AuthenticationFailed("Malformed header")
tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API) tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
if not tokens.exists(): if not tokens.exists():
raise AuthenticationFailed("Token invalid/expired") LOGGER.info("Authenticating via secret_key")
return tokens.first() 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): class TokenAuthentication(BaseAuthentication):
@ -49,9 +69,9 @@ class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication""" """Token-based authentication using HTTP Bearer authentication"""
auth = get_authorization_header(request) auth = get_authorization_header(request)
token = token_from_header(auth) user = bearer_auth(auth)
# None is only returned when the header isn't set. # None is only returned when the header isn't set.
if not token: if not user:
return None return None
return (token.user, None) # pragma: no cover return (user, None) # pragma: no cover

View File

@ -1,12 +1,14 @@
"""Test API Authentication""" """Test API Authentication"""
from base64 import b64encode from base64 import b64encode
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import token_from_header from authentik.api.authentication import bearer_auth
from authentik.core.models import Token, TokenIntents from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
from authentik.outposts.managed import OutpostManager
class TestAPIAuth(TestCase): class TestAPIAuth(TestCase):
@ -18,32 +20,41 @@ class TestAPIAuth(TestCase):
intent=TokenIntents.INTENT_API, user=get_anonymous_user() intent=TokenIntents.INTENT_API, user=get_anonymous_user()
) )
auth = b64encode(f":{token.key}".encode()).decode() 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): def test_valid_bearer(self):
"""Test valid token""" """Test valid token"""
token = Token.objects.create( token = Token.objects.create(
intent=TokenIntents.INTENT_API, user=get_anonymous_user() 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): def test_invalid_type(self):
"""Test invalid type""" """Test invalid type"""
with self.assertRaises(AuthenticationFailed): with self.assertRaises(AuthenticationFailed):
token_from_header("foo bar".encode()) bearer_auth("foo bar".encode())
def test_invalid_decode(self): def test_invalid_decode(self):
"""Test invalid bas64""" """Test invalid bas64"""
with self.assertRaises(AuthenticationFailed): with self.assertRaises(AuthenticationFailed):
token_from_header("Basic bar".encode()) bearer_auth("Basic bar".encode())
def test_invalid_empty_password(self): def test_invalid_empty_password(self):
"""Test invalid with empty password""" """Test invalid with empty password"""
with self.assertRaises(AuthenticationFailed): with self.assertRaises(AuthenticationFailed):
token_from_header("Basic :".encode()) bearer_auth("Basic :".encode())
def test_invalid_no_token(self): def test_invalid_no_token(self):
"""Test invalid with no token""" """Test invalid with no token"""
with self.assertRaises(AuthenticationFailed): with self.assertRaises(AuthenticationFailed):
auth = b64encode(":abc".encode()).decode() 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)

View File

@ -4,7 +4,7 @@ from channels.generic.websocket import JsonWebsocketConsumer
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from structlog.stdlib import get_logger 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 from authentik.core.models import User
LOGGER = get_logger() LOGGER = get_logger()
@ -24,12 +24,12 @@ class AuthJsonConsumer(JsonWebsocketConsumer):
raw_header = headers[b"authorization"] raw_header = headers[b"authorization"]
try: try:
token = token_from_header(raw_header) user = bearer_auth(raw_header)
# token is only None when no header was given, in which case we deny too # user is only None when no header was given, in which case we deny too
if not token: if not user:
raise DenyConnection() raise DenyConnection()
except AuthenticationFailed as exc: except AuthenticationFailed as exc:
LOGGER.warning("Failed to authenticate", exc=exc) LOGGER.warning("Failed to authenticate", exc=exc)
raise DenyConnection() raise DenyConnection()
self.user = token.user self.user = user

View File

@ -17,6 +17,7 @@ class AuthentikOutpostConfig(AppConfig):
def ready(self): def ready(self):
import_module("authentik.outposts.signals") import_module("authentik.outposts.signals")
import_module("authentik.outposts.managed")
try: try:
from authentik.outposts.tasks import outpost_local_connection from authentik.outposts.tasks import outpost_local_connection

View File

@ -2,6 +2,8 @@
from authentik.managed.manager import EnsureExists, ObjectManager from authentik.managed.manager import EnsureExists, ObjectManager
from authentik.outposts.models import Outpost, OutpostType from authentik.outposts.models import Outpost, OutpostType
MANAGED_OUTPOST = "goauthentik.io/outposts/embedded"
class OutpostManager(ObjectManager): class OutpostManager(ObjectManager):
"""Outpost managed objects""" """Outpost managed objects"""
@ -10,9 +12,8 @@ class OutpostManager(ObjectManager):
return [ return [
EnsureExists( EnsureExists(
Outpost, Outpost,
"goauthentik.io/outposts/inbuilt", MANAGED_OUTPOST,
name="authentik Bundeled Outpost", name="authentik Embedded Outpost",
object_field="name",
type=OutpostType.PROXY, type=OutpostType.PROXY,
), ),
] ]

View File

@ -1,4 +1,6 @@
"""outpost tests""" """outpost tests"""
from django.apps import apps
from django.contrib.auth.management import create_permissions
from django.test import TestCase from django.test import TestCase
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
@ -11,6 +13,10 @@ from authentik.providers.proxy.models import ProxyProvider
class OutpostTests(TestCase): class OutpostTests(TestCase):
"""Outpost Tests""" """Outpost Tests"""
def setUp(self) -> None:
create_permissions(apps.get_app_config("authentik_outposts"))
return super().setUp()
def test_service_account_permissions(self): def test_service_account_permissions(self):
"""Test that the service account has correct permissions""" """Test that the service account has correct permissions"""
provider: ProxyProvider = ProxyProvider.objects.create( 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 # We add a provider, user should only have access to outpost and provider
outpost.providers.add(provider) outpost.providers.add(provider)
outpost.save()
permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by( permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by(
"content_type__model" "content_type__model"
) )
@ -53,7 +58,6 @@ class OutpostTests(TestCase):
# Remove provider from outpost, user should only have access to outpost # Remove provider from outpost, user should only have access to outpost
outpost.providers.remove(provider) outpost.providers.remove(provider)
outpost.save()
permissions = UserObjectPermission.objects.filter(user=outpost.user) permissions = UserObjectPermission.objects.filter(user=outpost.user)
self.assertEqual(len(permissions), 1) self.assertEqual(len(permissions), 1)
self.assertEqual(permissions[0].object_pk, str(outpost.pk)) self.assertEqual(permissions[0].object_pk, str(outpost.pk))

View File

@ -2,6 +2,8 @@ package main
import ( import (
"fmt" "fmt"
"net/url"
"time"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -9,6 +11,8 @@ import (
"goauthentik.io/internal/config" "goauthentik.io/internal/config"
"goauthentik.io/internal/constants" "goauthentik.io/internal/constants"
"goauthentik.io/internal/gounicorn" "goauthentik.io/internal/gounicorn"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxy"
"goauthentik.io/internal/web" "goauthentik.io/internal/web"
) )
@ -25,6 +29,10 @@ func main() {
if err != nil { if err != nil {
log.WithError(err).Debug("failed to local config") log.WithError(err).Debug("failed to local config")
} }
err = config.FromEnv()
if err != nil {
log.WithError(err).Debug("failed to environment variables")
}
config.ConfigureLogger() config.ConfigureLogger()
if config.G.ErrorReporting.Enabled { if config.G.ErrorReporting.Enabled {
@ -43,7 +51,7 @@ func main() {
ex := common.Init() ex := common.Init()
defer common.Defer() defer common.Defer()
// u, _ := url.Parse("http://localhost:8000") u, _ := url.Parse("http://localhost:8000")
g := gounicorn.NewGoUnicorn() g := gounicorn.NewGoUnicorn()
ws := web.NewWebServer() ws := web.NewWebServer()
@ -52,7 +60,7 @@ func main() {
for { for {
go attemptStartBackend(g) go attemptStartBackend(g)
ws.Start() ws.Start()
// go attemptProxyStart(u) go attemptProxyStart(ws, u)
<-ex <-ex
running = false running = false
@ -73,35 +81,36 @@ func attemptStartBackend(g *gounicorn.GoUnicorn) {
} }
} }
// func attemptProxyStart(u *url.URL) error { func attemptProxyStart(ws *web.WebServer, u *url.URL) {
// maxTries := 100 maxTries := 100
// attempt := 0 attempt := 0
// for { // Sleep to wait for the app server to start
// log.WithField("logger", "authentik").Debug("attempting to init outpost") time.Sleep(30 * time.Second)
// ac := ak.NewAPIController(*u, config.G.SecretKey) for {
// if ac == nil { log.WithField("logger", "authentik").Debug("attempting to init outpost")
// attempt += 1 ac := ak.NewAPIController(*u, config.G.SecretKey)
// time.Sleep(1 * time.Second) if ac == nil {
// if attempt > maxTries { attempt += 1
// break time.Sleep(1 * time.Second)
// } if attempt > maxTries {
// continue break
// } }
// ac.Server = proxy.NewServer(ac) continue
// err := ac.Start() }
// log.WithField("logger", "authentik").Debug("attempting to start outpost") srv := proxy.NewServer(ac)
// if err != nil { ws.ProxyServer = srv
// attempt += 1 ac.Server = srv
// time.Sleep(5 * time.Second) log.WithField("logger", "authentik").Debug("attempting to start outpost")
// if attempt > maxTries { err := ac.StartBackgorundTasks()
// break if err != nil {
// } attempt += 1
// continue time.Sleep(15 * time.Second)
// } if attempt > maxTries {
// if !running { break
// ac.Shutdown() }
// return nil continue
// } } else {
// } select {}
// return nil }
// } }
}

1
go.mod
View File

@ -3,6 +3,7 @@ module goauthentik.io
go 1.16 go 1.16
require ( require (
github.com/Netflix/go-env v0.0.0-20210215222557-e437a7e7f9fb // indirect
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/coreos/go-oidc v2.2.1+incompatible github.com/coreos/go-oidc v2.2.1+incompatible
github.com/getsentry/sentry-go v0.11.0 github.com/getsentry/sentry-go v0.11.0

2
go.sum
View File

@ -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/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/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/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/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.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=

View File

@ -17,6 +17,6 @@ func Init() chan os.Signal {
} }
func Defer() { func Defer() {
defer sentry.Flush(time.Second * 5) sentry.Flush(time.Second * 5)
defer sentry.Recover() sentry.Recover()
} }

View File

@ -2,8 +2,8 @@ package config
import ( import (
"io/ioutil" "io/ioutil"
"os"
env "github.com/Netflix/go-env"
"github.com/imdario/mergo" "github.com/imdario/mergo"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -35,9 +35,8 @@ func LoadConfig(path string) error {
if err != nil { if err != nil {
return errors.Wrap(err, "Failed to load config file") return errors.Wrap(err, "Failed to load config file")
} }
rawExpanded := os.ExpandEnv(string(raw))
nc := Config{} nc := Config{}
err = yaml.Unmarshal([]byte(rawExpanded), &nc) err = yaml.Unmarshal(raw, &nc)
if err != nil { if err != nil {
return errors.Wrap(err, "Failed to parse YAML") return errors.Wrap(err, "Failed to parse YAML")
} }
@ -48,6 +47,19 @@ func LoadConfig(path string) error {
return nil 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() { func ConfigureLogger() {
switch G.LogLevel { switch G.LogLevel {
case "trace": case "trace":

View File

@ -1,18 +1,18 @@
package config package config
type Config struct { type Config struct {
Debug bool `yaml:"debug"` Debug bool `yaml:"debug" env:"AUTHENTIK_DEBUG"`
SecretKey string `yaml:"secret_key"` SecretKey string `yaml:"secret_key" env:"AUTHENTIK_SECRET_KEY"`
Web WebConfig `yaml:"web"` Web WebConfig `yaml:"web"`
Paths PathsConfig `yaml:"paths"` Paths PathsConfig `yaml:"paths"`
LogLevel string `yaml:"log_level"` LogLevel string `yaml:"log_level" env:"AUTHENTIK_LOG_LEVEL"`
ErrorReporting ErrorReportingConfig `yaml:"error_reporting"` ErrorReporting ErrorReportingConfig `yaml:"error_reporting"`
} }
type WebConfig struct { type WebConfig struct {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
ListenTLS string `yaml:"listen_tls"` 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 { type PathsConfig struct {
@ -20,7 +20,7 @@ type PathsConfig struct {
} }
type ErrorReportingConfig struct { type ErrorReportingConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled" env:"AUTHENTIK_ERROR_REPORTING__ENABLED"`
Environment string `yaml:"environment"` Environment string `yaml:"environment" env:"AUTHENTIK_ERROR_REPORTING__ENVIRONMENT"`
SendPII bool `yaml:"send_pii"` SendPII bool `yaml:"send_pii" env:"AUTHENTIK_ERROR_REPORTING__SEND_PII"`
} }

View File

@ -80,6 +80,20 @@ func NewAPIController(akURL url.URL, token string) *APIController {
// Start Starts all handlers, non-blocking // Start Starts all handlers, non-blocking
func (a *APIController) Start() error { 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() err := a.Server.Refresh()
if err != nil { if err != nil {
return errors.Wrap(err, "failed to run initial refresh") 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.logger.Debug("Starting Interval updater...")
a.startIntervalUpdater() a.startIntervalUpdater()
}() }()
go func() {
err := a.Server.Start()
if err != nil {
panic(err)
}
}()
return nil return nil
} }

View File

@ -8,6 +8,7 @@ import (
sessionsapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions" sessionsapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/pkg/cookies" "github.com/oauth2-proxy/oauth2-proxy/pkg/cookies"
"goauthentik.io/internal/utils/web"
) )
// MakeCSRFCookie creates a cookie for CSRF // 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) cookieDomain := cookies.GetCookieDomain(req, p.CookieDomains)
if cookieDomain != "" { if cookieDomain != "" {
domain := getHost(req) domain := web.GetHost(req)
if h, _, err := net.SplitHostPort(domain); err == nil { if h, _, err := net.SplitHostPort(domain); err == nil {
domain = h domain = h
} }

View File

@ -9,6 +9,7 @@ import (
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"goauthentik.io/internal/utils/web"
) )
// responseLogger is wrapper of http.ResponseWriter that keeps track of its HTTP status // 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) duration := float64(time.Since(t)) / float64(time.Millisecond)
h.logger.WithFields(log.Fields{ h.logger.WithFields(log.Fields{
"host": req.RemoteAddr, "host": req.RemoteAddr,
"vhost": getHost(req), "vhost": web.GetHost(req),
"request_protocol": req.Proto, "request_protocol": req.Proto,
"runtime": fmt.Sprintf("%0.3f", duration), "runtime": fmt.Sprintf("%0.3f", duration),
"method": req.Method, "method": req.Method,

View File

@ -21,6 +21,7 @@ import (
"github.com/oauth2-proxy/oauth2-proxy/pkg/upstream" "github.com/oauth2-proxy/oauth2-proxy/pkg/upstream"
"github.com/oauth2-proxy/oauth2-proxy/providers" "github.com/oauth2-proxy/oauth2-proxy/providers"
"goauthentik.io/api" "goauthentik.io/api"
"goauthentik.io/internal/utils/web"
log "github.com/sirupsen/logrus" 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 // Optional suffix, which is appended to the URL
suffix := "" suffix := ""
if p.mode == api.PROXYMODE_FORWARD_SINGLE { if p.mode == api.PROXYMODE_FORWARD_SINGLE {
host = getHost(req) host = web.GetHost(req)
} else if p.mode == api.PROXYMODE_FORWARD_DOMAIN { } else if p.mode == api.PROXYMODE_FORWARD_DOMAIN {
host = p.ExternalHost host = p.ExternalHost
// set the ?rd flag to the current URL we have, since we redirect // set the ?rd flag to the current URL we have, since we redirect

View File

@ -4,19 +4,23 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt"
"net" "net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/pires/go-proxyproto"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"goauthentik.io/internal/crypto" "goauthentik.io/internal/crypto"
"goauthentik.io/internal/outpost/ak" "goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/utils/web"
) )
// Server represents an HTTP server // Server represents an HTTP server
type Server struct { type Server struct {
Handlers map[string]*providerBundle Handlers map[string]*providerBundle
Listen string
stop chan struct{} // channel for waiting shutdown stop chan struct{} // channel for waiting shutdown
logger *log.Entry logger *log.Entry
@ -33,6 +37,7 @@ func NewServer(ac *ak.APIController) *Server {
} }
return &Server{ return &Server{
Handlers: make(map[string]*providerBundle), Handlers: make(map[string]*providerBundle),
Listen: "0.0.0.0:%d",
logger: log.WithField("logger", "authentik.outpost.proxy-http-server"), logger: log.WithField("logger", "authentik.outpost.proxy-http-server"),
defaultCert: defaultCert, defaultCert: defaultCert,
ak: ac, 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" { if r.URL.Path == "/akprox/ping" {
w.WriteHeader(204) w.WriteHeader(204)
return return
} }
host := getHost(r) host := web.GetHost(r)
handler, ok := s.Handlers[host] handler, ok := s.Handlers[host]
if !ok { if !ok {
// If we only have one handler, host name switching doesn't matter // 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) { 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 // See https://golang.org/pkg/net/http/#Server.Shutdown
idleConnsClosed := make(chan struct{}) idleConnsClosed := make(chan struct{})

View File

@ -2,27 +2,13 @@ package proxy
import ( import (
"crypto/tls" "crypto/tls"
"fmt"
"net" "net"
"sync" "sync"
"github.com/pires/go-proxyproto" "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) { func (s *Server) getCertificates(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
handler, ok := s.Handlers[info.ServerName] handler, ok := s.Handlers[info.ServerName]
if !ok { 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 // ServeHTTPS constructs a net.Listener and starts handling HTTPS requests
func (s *Server) ServeHTTPS() { func (s *Server) ServeHTTPS() {
listenAddress := "0.0.0.0:4443" listenAddress := fmt.Sprintf(s.Listen, 4443)
config := &tls.Config{ config := &tls.Config{
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12,

View File

@ -1,25 +1,9 @@
package proxy package proxy
import ( import (
"net"
"net/http"
"strconv" "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 // toString Generic to string function, currently supports actual strings and integers
func toString(in interface{}) string { func toString(in interface{}) string {
switch v := in.(type) { switch v := in.(type) {

View File

@ -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
}

View File

@ -6,11 +6,12 @@ import (
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"goauthentik.io/internal/utils/web"
) )
func loggingMiddleware(next http.Handler) http.Handler { func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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() before := time.Now()
// Call the next handler, which can be another middleware in the chain, or the final handler. // Call the next handler, which can be another middleware in the chain, or the final handler.
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@ -19,6 +20,7 @@ func loggingMiddleware(next http.Handler) http.Handler {
"remote": r.RemoteAddr, "remote": r.RemoteAddr,
"method": r.Method, "method": r.Method,
"took": after.Sub(before), "took": after.Sub(before),
"host": web.GetHost(r),
}).Info(r.RequestURI) }).Info(r.RequestURI)
span.Finish() span.Finish()
}) })

View File

@ -11,6 +11,7 @@ import (
"github.com/pires/go-proxyproto" "github.com/pires/go-proxyproto"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config" "goauthentik.io/internal/config"
"goauthentik.io/internal/outpost/proxy"
) )
type WebServer struct { type WebServer struct {
@ -21,6 +22,8 @@ type WebServer struct {
stop chan struct{} // channel for waiting shutdown stop chan struct{} // channel for waiting shutdown
ProxyServer *proxy.Server
m *mux.Router m *mux.Router
lh *mux.Router lh *mux.Router
log *log.Entry log *log.Entry

View File

@ -1,9 +1,12 @@
package web package web
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"goauthentik.io/internal/utils/web"
) )
func (ws *WebServer) configureProxy() { func (ws *WebServer) configureProxy() {
@ -23,7 +26,25 @@ func (ws *WebServer) configureProxy() {
rp := &httputil.ReverseProxy{Director: director} rp := &httputil.ReverseProxy{Director: director}
rp.ErrorHandler = ws.proxyErrorHandler rp.ErrorHandler = ws.proxyErrorHandler
rp.ModifyResponse = ws.proxyModifyResponse 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) { func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request, err error) {

View File

@ -19,6 +19,7 @@ from ldap3.core.exceptions import LDAPInvalidCredentialsResult
from authentik.core.models import Application, Group, User from authentik.core.models import Application, Group, User
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.outposts.managed import MANAGED_OUTPOST
from authentik.outposts.models import Outpost, OutpostType from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.ldap.models import LDAPProvider from authentik.providers.ldap.models import LDAPProvider
from tests.e2e.utils import ( from tests.e2e.utils import (
@ -193,6 +194,9 @@ class TestProviderLDAP(SeleniumTestCase):
}, },
) )
) )
embedded_account = Outpost.objects.filter(managed=MANAGED_OUTPOST).first().user
_connection.search( _connection.search(
"ou=users,dc=ldap,dc=goauthentik,dc=io", "ou=users,dc=ldap,dc=goauthentik,dc=io",
"(objectClass=user)", "(objectClass=user)",
@ -232,6 +236,31 @@ class TestProviderLDAP(SeleniumTestCase):
}, },
"type": "searchResEntry", "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", "dn": f"cn={USER().username},ou=users,dc=ldap,dc=goauthentik,dc=io",
"attributes": { "attributes": {

View File

@ -164,6 +164,7 @@ class TestSourceSAML(SeleniumTestCase):
self.assert_user( self.assert_user(
User.objects.exclude(username="akadmin") User.objects.exclude(username="akadmin")
.exclude(username__startswith="ak-outpost")
.exclude(pk=get_anonymous_user().pk) .exclude(pk=get_anonymous_user().pk)
.first() .first()
) )
@ -249,6 +250,7 @@ class TestSourceSAML(SeleniumTestCase):
self.assert_user( self.assert_user(
User.objects.exclude(username="akadmin") User.objects.exclude(username="akadmin")
.exclude(username__startswith="ak-outpost")
.exclude(pk=get_anonymous_user().pk) .exclude(pk=get_anonymous_user().pk)
.first() .first()
) )
@ -321,6 +323,7 @@ class TestSourceSAML(SeleniumTestCase):
self.assert_user( self.assert_user(
User.objects.exclude(username="akadmin") User.objects.exclude(username="akadmin")
.exclude(username__startswith="ak-outpost")
.exclude(pk=get_anonymous_user().pk) .exclude(pk=get_anonymous_user().pk)
.first() .first()
) )

View File

@ -48,6 +48,7 @@ class SeleniumTestCase(StaticLiveServerTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.maxDiff = None
self.wait_timeout = 60 self.wait_timeout = 60
self.driver = self._get_driver() self.driver = self._get_driver()
self.driver.maximize_window() self.driver.maximize_window()

View File

@ -17,6 +17,9 @@ export class OutpostHealthElement extends LitElement {
@property({attribute: false}) @property({attribute: false})
outpostHealth?: OutpostHealth[]; outpostHealth?: OutpostHealth[];
@property({attribute: false})
showVersion = true;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase, AKGlobal]; return [PFBase, AKGlobal];
} }
@ -56,12 +59,13 @@ export class OutpostHealthElement extends LitElement {
<li role="cell"> <li role="cell">
<ak-label color=${PFColor.Green} text=${t`Last seen: ${h.lastSeen?.toLocaleTimeString()}`}></ak-label> <ak-label color=${PFColor.Green} text=${t`Last seen: ${h.lastSeen?.toLocaleTimeString()}`}></ak-label>
</li> </li>
<li role="cell"> ${this.showVersion ?
html`<li role="cell">
${h.versionOutdated ? ${h.versionOutdated ?
html`<ak-label color=${PFColor.Red} html`<ak-label color=${PFColor.Red}
text=${t`${h.version}, should be ${h.versionShould}`}></ak-label>` : text=${t`${h.version}, should be ${h.versionShould}`}></ak-label>` :
html`<ak-label color=${PFColor.Green} text=${t`Version: ${h.version || ""}`}></ak-label>`} html`<ak-label color=${PFColor.Green} text=${t`Version: ${h.version || ""}`}></ak-label>`}
</li> </li>` : html``}
</ul> </ul>
</li>`; </li>`;
})}</ul>`; })}</ul>`;

View File

@ -53,6 +53,9 @@ export class OutpostListPage extends TablePage<Outpost> {
order = "name"; order = "name";
row(item: Outpost): TemplateResult[] { row(item: Outpost): TemplateResult[] {
if (item.managed === "goauthentik.io/outposts/embedded") {
return this.rowInbuilt(item);
}
return [ return [
html`${item.name}`, html`${item.name}`,
html`<ul>${item.providersObj?.map((p) => { html`<ul>${item.providersObj?.map((p) => {
@ -99,6 +102,30 @@ export class OutpostListPage extends TablePage<Outpost> {
]; ];
} }
rowInbuilt(item: Outpost): TemplateResult[] {
return [
html`${item.name}`,
html`<ul>${item.providersObj?.map((p) => {
return html`<li><a href="#/core/providers/${p.pk}">${p.name}</a></li>`;
})}</ul>`,
html`-`,
html`<ak-outpost-health ?showVersion=${false} outpostId=${ifDefined(item.pk)}></ak-outpost-health>`,
html`<ak-forms-modal>
<span slot="submit">
${t`Update`}
</span>
<span slot="header">
${t`Update Outpost`}
</span>
<ak-outpost-form slot="form" .instancePk=${item.pk}>
</ak-outpost-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${t`Edit`}
</button>
</ak-forms-modal>`,
];
}
renderToolbar(): TemplateResult { renderToolbar(): TemplateResult {
return html` return html`
<ak-forms-modal> <ak-forms-modal>