From ed7092c59a1059f4f0e40379102f6caedd48ed69 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Sat, 1 Oct 2022 21:47:09 +1000 Subject: [PATCH] feat: envoy support (#3793) Adds support for Envoy and Istio using the X-Authelia-URL header. The documentation will be published just before the release. Co-authored-by: Amir Zarrinkafsh --- cmd/authelia-gen/cmd_github.go | 2 +- config.template.yml | 2 +- internal/configuration/config.template.yml | 2 +- internal/handlers/handler_sign_duo_test.go | 8 +- internal/handlers/handler_sign_totp_test.go | 6 +- internal/handlers/handler_verify.go | 36 +-- internal/handlers/handler_verify_test.go | 135 +++++++++- internal/handlers/util.go | 26 ++ internal/middlewares/authelia_context.go | 10 + internal/middlewares/const.go | 4 + internal/server/handlers.go | 3 + internal/suites/Envoy/configuration.yml | 54 ++++ internal/suites/Envoy/docker-compose.yml | 9 + internal/suites/Envoy/users.yml | 35 +++ .../example/compose/envoy/docker-compose.yml | 12 + .../suites/example/compose/envoy/envoy.yaml | 236 ++++++++++++++++++ internal/suites/suite_envoy.go | 84 +++++++ internal/suites/suite_envoy_test.go | 39 +++ 18 files changed, 676 insertions(+), 27 deletions(-) create mode 100644 internal/handlers/util.go create mode 100644 internal/suites/Envoy/configuration.yml create mode 100644 internal/suites/Envoy/docker-compose.yml create mode 100644 internal/suites/Envoy/users.yml create mode 100644 internal/suites/example/compose/envoy/docker-compose.yml create mode 100644 internal/suites/example/compose/envoy/envoy.yaml create mode 100644 internal/suites/suite_envoy.go create mode 100644 internal/suites/suite_envoy_test.go diff --git a/cmd/authelia-gen/cmd_github.go b/cmd/authelia-gen/cmd_github.go index 8d8ec2f49..99d80378a 100644 --- a/cmd/authelia-gen/cmd_github.go +++ b/cmd/authelia-gen/cmd_github.go @@ -204,7 +204,7 @@ func cmdGitHubIssueTemplatesBugReportRunE(cmd *cobra.Command, args []string) (er data := &tmplIssueTemplateData{ Labels: []string{labelTypeBugUnconfirmed.String(), labelStatusNeedsTriage.String(), labelPriorityNormal.String()}, Versions: tagsRecent, - Proxies: []string{"Caddy", "Traefik", "Envoy", "NGINX", "SWAG", "NGINX Proxy Manager", "HAProxy"}, + Proxies: []string{"Caddy", "Traefik", "Envoy", "Istio", "NGINX", "SWAG", "NGINX Proxy Manager", "HAProxy"}, } if err = tmplGitHubIssueTemplateBug.Execute(f, data); err != nil { diff --git a/config.template.yml b/config.template.yml index a878e05f4..85f1bea72 100644 --- a/config.template.yml +++ b/config.template.yml @@ -443,7 +443,7 @@ password_policy: ## to anyone. Otherwise restrictions follow the rules defined. ## ## Note: One can use the wildcard * to match any subdomain. -## It must stand at the beginning of the pattern. (example: *.mydomain.com) +## It must stand at the beginning of the pattern. (example: *.example.com) ## ## Note: You must put patterns containing wildcards between simple quotes for the YAML to be syntactically correct. ## diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index a878e05f4..85f1bea72 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -443,7 +443,7 @@ password_policy: ## to anyone. Otherwise restrictions follow the rules defined. ## ## Note: One can use the wildcard * to match any subdomain. -## It must stand at the beginning of the pattern. (example: *.mydomain.com) +## It must stand at the beginning of the pattern. (example: *.example.com) ## ## Note: You must put patterns containing wildcards between simple quotes for the YAML to be syntactically correct. ## diff --git a/internal/handlers/handler_sign_duo_test.go b/internal/handlers/handler_sign_duo_test.go index 201bb521f..771fc49bf 100644 --- a/internal/handlers/handler_sign_duo_test.go +++ b/internal/handlers/handler_sign_duo_test.go @@ -614,14 +614,14 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() { duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil) bodyBytes, err := json.Marshal(signDuoRequestBody{ - TargetURL: "https://example.com", + TargetURL: "https://mydomain.example.com", }) s.Require().NoError(err) s.mock.Ctx.Request.SetBody(bodyBytes) DuoPOST(duoMock)(s.mock.Ctx) s.mock.Assert200OK(s.T(), redirectResponse{ - Redirect: "https://example.com", + Redirect: "https://mydomain.example.com", }) } @@ -663,7 +663,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() { duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil) bodyBytes, err := json.Marshal(signDuoRequestBody{ - TargetURL: "http://example.com", + TargetURL: "http://mydomain.example.com", }) s.Require().NoError(err) s.mock.Ctx.Request.SetBody(bodyBytes) @@ -710,7 +710,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessi duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil) bodyBytes, err := json.Marshal(signDuoRequestBody{ - TargetURL: "http://example.com", + TargetURL: "http://mydomain.example.com", }) s.Require().NoError(err) s.mock.Ctx.Request.SetBody(bodyBytes) diff --git a/internal/handlers/handler_sign_totp_test.go b/internal/handlers/handler_sign_totp_test.go index e5e38e9f3..13b84c351 100644 --- a/internal/handlers/handler_sign_totp_test.go +++ b/internal/handlers/handler_sign_totp_test.go @@ -167,7 +167,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToSafeTargetURL() { bodyBytes, err := json.Marshal(signTOTPRequestBody{ Token: "abc", - TargetURL: "https://example.com", + TargetURL: "https://mydomain.example.com", }) s.Require().NoError(err) @@ -175,7 +175,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToSafeTargetURL() { TimeBasedOneTimePasswordPOST(s.mock.Ctx) s.mock.Assert200OK(s.T(), redirectResponse{ - Redirect: "https://example.com", + Redirect: "https://mydomain.example.com", }) } @@ -205,7 +205,7 @@ func (s *HandlerSignTOTPSuite) TestShouldNotRedirectToUnsafeURL() { bodyBytes, err := json.Marshal(signTOTPRequestBody{ Token: "abc", - TargetURL: "http://example.com", + TargetURL: "http://mydomain.example.com", }) s.Require().NoError(err) diff --git a/internal/handlers/handler_verify.go b/internal/handlers/handler_verify.go index a78d699ca..eacd23b43 100644 --- a/internal/handlers/handler_verify.go +++ b/internal/handlers/handler_verify.go @@ -180,7 +180,6 @@ func verifySessionCookie(ctx *middlewares.AutheliaCtx, targetURL *url.URL, userS func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, isBasicAuth bool, username string, method []byte) { var ( statusCode int - redirectionURL string friendlyUsername string friendlyRequestMethod string ) @@ -200,10 +199,6 @@ func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, is return } - // Kubernetes ingress controller and Traefik use the rd parameter of the verify - // endpoint to provide the URL of the login portal. The target URL of the user - // is computed from X-Forwarded-* headers or X-Original-URL. - rd := string(ctx.QueryArgs().Peek("rd")) rm := string(method) switch rm { @@ -213,17 +208,30 @@ func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, is friendlyRequestMethod = rm } - if rd != "" { - switch rm { - case "": - redirectionURL = fmt.Sprintf("%s?rd=%s", rd, url.QueryEscape(targetURL.String())) - default: - redirectionURL = fmt.Sprintf("%s?rd=%s&rm=%s", rd, url.QueryEscape(targetURL.String()), rm) + redirectionURL := ctxGetPortalURL(ctx) + + if redirectionURL != nil { + if !utils.IsURISafeRedirection(redirectionURL, ctx.Configuration.Session.Domain) { + ctx.Logger.Errorf("Configured Portal URL '%s' does not appear to be able to write cookies for the '%s' domain", redirectionURL, ctx.Configuration.Session.Domain) + + ctx.ReplyUnauthorized() + + return } + + qry := redirectionURL.Query() + + qry.Set("rd", targetURL.String()) + + if rm != "" { + qry.Set("rm", rm) + } + + redirectionURL.RawQuery = qry.Encode() } switch { - case ctx.IsXHR() || !ctx.AcceptsMIME("text/html") || rd == "": + case ctx.IsXHR() || !ctx.AcceptsMIME("text/html") || redirectionURL == nil: statusCode = fasthttp.StatusUnauthorized default: switch rm { @@ -234,9 +242,9 @@ func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, is } } - if redirectionURL != "" { + if redirectionURL != nil { ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, responding with status code %d with location redirect to %s", targetURL.String(), friendlyRequestMethod, friendlyUsername, statusCode, redirectionURL) - ctx.SpecialRedirect(redirectionURL, statusCode) + ctx.SpecialRedirect(redirectionURL.String(), statusCode) } else { ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, responding with status code %d", targetURL.String(), friendlyRequestMethod, friendlyUsername, statusCode) ctx.ReplyUnauthorized() diff --git a/internal/handlers/handler_verify_test.go b/internal/handlers/handler_verify_test.go index 42e66fee2..ae2cc9d25 100644 --- a/internal/handlers/handler_verify_test.go +++ b/internal/handlers/handler_verify_test.go @@ -453,7 +453,7 @@ func TestShouldRedirectWithGroups(t *testing.T) { mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, "app.example.com") mock.Ctx.Request.Header.Set("X-Forwarded-Uri", "/code-test/login") - mock.Ctx.Request.SetRequestURI("/?rd=https://auth.mydomain.com") + mock.Ctx.Request.SetRequestURI("/api/verify/?rd=https://auth.example.com") VerifyGET(verifyGetCfg)(mock.Ctx) @@ -537,6 +537,110 @@ func (p Pair) String() string { p.URL, p.Username, p.AuthenticationLevel, p.ExpectedStatusCode) } +//nolint:gocyclo // This is a test. +func TestShouldRedirectAuthorizations(t *testing.T) { + testCases := []struct { + name string + + method, originalURL, autheliaURL string + + expected int + }{ + {"ShouldReturnFoundMethodNone", "", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusFound}, + {"ShouldReturnFoundMethodGET", "GET", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusFound}, + {"ShouldReturnFoundMethodOPTIONS", "OPTIONS", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusFound}, + {"ShouldReturnSeeOtherMethodPOST", "POST", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusSeeOther}, + {"ShouldReturnSeeOtherMethodPATCH", "PATCH", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusSeeOther}, + {"ShouldReturnSeeOtherMethodPUT", "PUT", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusSeeOther}, + {"ShouldReturnSeeOtherMethodDELETE", "DELETE", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusSeeOther}, + {"ShouldReturnUnauthorizedBadDomain", "GET", "https://one-factor.example.com/", "https://auth.notexample.com/", fasthttp.StatusUnauthorized}, + } + + handler := VerifyGET(verifyGetCfg) + + for _, tc := range testCases { + var ( + suffix string + xhr bool + ) + + for i := 0; i < 2; i++ { + switch i { + case 0: + suffix += "QueryParameter" + default: + suffix += "RequestHeader" + } + + for j := 0; j < 2; j++ { + switch j { + case 0: + xhr = false + case 1: + xhr = true + suffix += "XHR" + } + + t.Run(tc.name+suffix, func(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Clock.Set(time.Now()) + + autheliaURL, err := url.ParseRequestURI(tc.autheliaURL) + + require.NoError(t, err) + + originalURL, err := url.ParseRequestURI(tc.originalURL) + + require.NoError(t, err) + + if xhr { + mock.Ctx.Request.Header.Set(fasthttp.HeaderXRequestedWith, "XMLHttpRequest") + } + + var rm string + + if tc.method != "" { + rm = fmt.Sprintf("&rm=%s", tc.method) + mock.Ctx.Request.Header.Set("X-Forwarded-Method", tc.method) + } + + mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") + mock.Ctx.Request.Header.Set("X-Original-URL", originalURL.String()) + + if i == 0 { + mock.Ctx.Request.SetRequestURI(fmt.Sprintf("/?rd=%s", url.QueryEscape(autheliaURL.String()))) + } else { + mock.Ctx.Request.Header.Set("X-Authelia-URL", autheliaURL.String()) + } + + handler(mock.Ctx) + + if xhr && tc.expected != fasthttp.StatusUnauthorized { + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + } else { + assert.Equal(t, tc.expected, mock.Ctx.Response.StatusCode()) + } + + switch { + case xhr && tc.expected != fasthttp.StatusUnauthorized: + href := utils.StringHTMLEscape(fmt.Sprintf("%s?rd=%s%s", autheliaURL.String(), url.QueryEscape(originalURL.String()), rm)) + assert.Equal(t, fmt.Sprintf("%d %s", href, fasthttp.StatusUnauthorized, fasthttp.StatusMessage(fasthttp.StatusUnauthorized)), string(mock.Ctx.Response.Body())) + case tc.expected >= fasthttp.StatusMultipleChoices && tc.expected < fasthttp.StatusBadRequest: + href := utils.StringHTMLEscape(fmt.Sprintf("%s?rd=%s%s", autheliaURL.String(), url.QueryEscape(originalURL.String()), rm)) + assert.Equal(t, fmt.Sprintf("%d %s", href, tc.expected, fasthttp.StatusMessage(tc.expected)), string(mock.Ctx.Response.Body())) + case tc.expected < fasthttp.StatusMultipleChoices: + assert.Equal(t, utils.StringHTMLEscape(fmt.Sprintf("%d %s", tc.expected, fasthttp.StatusMessage(tc.expected))), string(mock.Ctx.Response.Body())) + default: + assert.Equal(t, utils.StringHTMLEscape(fmt.Sprintf("%d %s", tc.expected, fasthttp.StatusMessage(tc.expected))), string(mock.Ctx.Response.Body())) + } + }) + } + } + } +} + func TestShouldVerifyAuthorizationsUsingSessionCookie(t *testing.T) { testCases := []Pair{ // should apply default policy. @@ -837,11 +941,36 @@ func TestShouldURLEncodeRedirectionURLParameter(t *testing.T) { mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") mock.Ctx.Request.SetHost("mydomain.com") - mock.Ctx.Request.SetRequestURI("/?rd=https://auth.mydomain.com") + mock.Ctx.Request.SetRequestURI("/?rd=https://auth.example.com") VerifyGET(verifyGetCfg)(mock.Ctx) - assert.Equal(t, "302 Found", + assert.Equal(t, "302 Found", + string(mock.Ctx.Response.Body())) +} + +func TestShouldURLEncodeRedirectionHeader(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + mock.Clock.Set(time.Now()) + + userSession := mock.Ctx.GetSession() + userSession.Username = testUsername + userSession.AuthenticationLevel = authentication.NotAuthenticated + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + err := mock.Ctx.SaveSession(userSession) + require.NoError(t, err) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") + mock.Ctx.Request.Header.Set("X-Authelia-URL", "https://auth.example.com") + mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") + mock.Ctx.Request.SetHost("mydomain.com") + + VerifyGET(verifyGetCfg)(mock.Ctx) + + assert.Equal(t, "302 Found", string(mock.Ctx.Response.Body())) } diff --git a/internal/handlers/util.go b/internal/handlers/util.go new file mode 100644 index 000000000..3117b74bf --- /dev/null +++ b/internal/handlers/util.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "bytes" + "net/url" + + "github.com/authelia/authelia/v4/internal/middlewares" +) + +var bytesEmpty = []byte("") + +func ctxGetPortalURL(ctx *middlewares.AutheliaCtx) (portalURL *url.URL) { + var rawURL []byte + + if rawURL = ctx.QueryArgRedirect(); rawURL != nil && !bytes.Equal(rawURL, bytesEmpty) { + portalURL, _ = url.ParseRequestURI(string(rawURL)) + + return portalURL + } else if rawURL = ctx.XAutheliaURL(); rawURL != nil && !bytes.Equal(rawURL, bytesEmpty) { + portalURL, _ = url.ParseRequestURI(string(rawURL)) + + return portalURL + } + + return nil +} diff --git a/internal/middlewares/authelia_context.go b/internal/middlewares/authelia_context.go index 5189e1435..aa7fd4926 100644 --- a/internal/middlewares/authelia_context.go +++ b/internal/middlewares/authelia_context.go @@ -171,6 +171,16 @@ func (ctx *AutheliaCtx) XForwardedURI() (uri []byte) { return uri } +// XAutheliaURL return the content of the X-Authelia-URL header. +func (ctx *AutheliaCtx) XAutheliaURL() (autheliaURL []byte) { + return ctx.RequestCtx.Request.Header.PeekBytes(headerXAutheliaURL) +} + +// QueryArgRedirect return the content of the rd query argument. +func (ctx *AutheliaCtx) QueryArgRedirect() (val []byte) { + return ctx.RequestCtx.QueryArgs().PeekBytes(queryArgRedirect) +} + // BasePath returns the base_url as per the path visited by the client. func (ctx *AutheliaCtx) BasePath() (base string) { if baseURL := ctx.UserValueBytes(UserValueKeyBaseURL); baseURL != nil { diff --git a/internal/middlewares/const.go b/internal/middlewares/const.go index bbbbb8235..88ef4472b 100644 --- a/internal/middlewares/const.go +++ b/internal/middlewares/const.go @@ -7,6 +7,8 @@ import ( ) var ( + headerXAutheliaURL = []byte("X-Authelia-URL") + headerAccept = []byte(fasthttp.HeaderAccept) headerContentLength = []byte(fasthttp.HeaderContentLength) headerLocation = []byte(fasthttp.HeaderLocation) @@ -71,6 +73,8 @@ var ( protoHTTPS = []byte(strProtoHTTPS) protoHTTP = []byte(strProtoHTTP) + queryArgRedirect = []byte("rd") + // UserValueKeyBaseURL is the User Value key where we store the Base URL. UserValueKeyBaseURL = []byte("base_url") diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 9f77e0bbb..b9c9c95da 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -167,6 +167,9 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers) r.GET("/api/verify", middlewares.Wrap(metricsVRMW, middleware(handlers.VerifyGET(config.AuthenticationBackend)))) r.HEAD("/api/verify", middlewares.Wrap(metricsVRMW, middleware(handlers.VerifyGET(config.AuthenticationBackend)))) + r.GET("/api/verify/{path:*}", middlewares.Wrap(metricsVRMW, middleware(handlers.VerifyGET(config.AuthenticationBackend)))) + r.HEAD("/api/verify/{path:*}", middlewares.Wrap(metricsVRMW, middleware(handlers.VerifyGET(config.AuthenticationBackend)))) + r.POST("/api/checks/safe-redirection", middlewareAPI(handlers.CheckSafeRedirectionPOST)) delayFunc := middlewares.TimingAttackDelay(10, 250, 85, time.Second, true) diff --git a/internal/suites/Envoy/configuration.yml b/internal/suites/Envoy/configuration.yml new file mode 100644 index 000000000..7a5f55448 --- /dev/null +++ b/internal/suites/Envoy/configuration.yml @@ -0,0 +1,54 @@ +--- +############################################################### +# Authelia minimal configuration # +############################################################### + +jwt_secret: unsecure_secret + +server: + port: 9091 + asset_path: /config/assets/ + tls: + certificate: /config/ssl/cert.pem + key: /config/ssl/key.pem + +log: + level: debug + +authentication_backend: + file: + path: /config/users.yml + +session: + secret: unsecure_session_secret + domain: example.com + expiration: 3600 # 1 hour + inactivity: 300 # 5 minutes + remember_me_duration: 1y + +storage: + encryption_key: a_not_so_secure_encryption_key + local: + path: /config/db.sqlite + +access_control: + default_policy: bypass + rules: + - domain: "login.example.com" + policy: bypass + - domain: "public.example.com" + policy: bypass + - domain: "admin.example.com" + policy: two_factor + - domain: "secure.example.com" + policy: two_factor + - domain: "singlefactor.example.com" + policy: one_factor + +notifier: + smtp: + host: smtp + port: 1025 + sender: admin@example.com + disable_require_tls: true +... diff --git a/internal/suites/Envoy/docker-compose.yml b/internal/suites/Envoy/docker-compose.yml new file mode 100644 index 000000000..194d32ab4 --- /dev/null +++ b/internal/suites/Envoy/docker-compose.yml @@ -0,0 +1,9 @@ +--- +version: '3' +services: + authelia-backend: + volumes: + - './Envoy/configuration.yml:/config/configuration.yml:ro' + - './Envoy/users.yml:/config/users.yml' + - './common/ssl:/config/ssl:ro' +... diff --git a/internal/suites/Envoy/users.yml b/internal/suites/Envoy/users.yml new file mode 100644 index 000000000..a52978b20 --- /dev/null +++ b/internal/suites/Envoy/users.yml @@ -0,0 +1,35 @@ +--- +############################################################### +# Users Database # +############################################################### + +# This file can be used if you do not have an LDAP set up. + +# List of users +users: + john: + displayname: "John Doe" + password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length + email: john.doe@authelia.com + groups: + - admins + - dev + + harry: + displayname: "Harry Potter" + password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length + email: harry.potter@authelia.com + groups: [] + + bob: + displayname: "Bob Dylan" + password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length + email: bob.dylan@authelia.com + groups: + - dev + + james: + displayname: "James Dean" + password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length + email: james.dean@authelia.com +... diff --git a/internal/suites/example/compose/envoy/docker-compose.yml b/internal/suites/example/compose/envoy/docker-compose.yml new file mode 100644 index 000000000..691c93599 --- /dev/null +++ b/internal/suites/example/compose/envoy/docker-compose.yml @@ -0,0 +1,12 @@ +--- +version: '3' +services: + envoy: + image: envoyproxy/envoy:v1.23.0 + volumes: + - ./example/compose/envoy/envoy.yaml:/etc/envoy/envoy.yaml + - ./example/compose/nginx/portal/ssl:/etc/ssl + networks: + authelianet: + ipv4_address: 192.168.240.100 +... diff --git a/internal/suites/example/compose/envoy/envoy.yaml b/internal/suites/example/compose/envoy/envoy.yaml new file mode 100644 index 000000000..fc5039ebe --- /dev/null +++ b/internal/suites/example/compose/envoy/envoy.yaml @@ -0,0 +1,236 @@ +--- +static_resources: + listeners: + - name: listener_0 + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager # yamllint disable-line rule:line-length + stat_prefix: ingress_http + use_remote_address: true + skip_xff_append: false + access_log: + - name: envoy.access_loggers.stdout + typed_config: + "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog + route_config: + name: local_route + virtual_hosts: + - name: login_service + domains: ["login.example.com:8080"] + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + routes: + - match: + prefix: "/.well-known/" + route: + cluster: authelia-backend + - match: + prefix: "/api/" + route: + cluster: authelia-backend + - match: + prefix: "/locales/" + route: + cluster: authelia-backend + - match: + path: "/jwks.json" + route: + cluster: authelia-backend + - match: + prefix: "/" + route: + cluster: authelia-frontend + - name: mail_service + domains: ["mail.example.com:8080"] + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + routes: + - match: + prefix: "/" + route: + cluster: smtp + - name: http_service + domains: ["*.example.com:8080"] + routes: + - match: + prefix: "/headers" + route: + cluster: httpbin + - match: + prefix: "/" + route: + cluster: nginx-backend + http_filters: + - name: envoy.filters.http.ext_authz + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + http_service: + path_prefix: /api/verify/ + server_uri: + uri: authelia-backend:9091 + cluster: authelia-backend + timeout: 0.25s + authorization_request: + allowed_headers: + patterns: + - exact: accept + - exact: cookie + - exact: proxy-authorization + headers_to_add: + - key: X-Authelia-URL + value: 'https://login.example.com:8080/' + - key: X-Forwarded-Method + value: '%REQ(:METHOD)%' + - key: X-Forwarded-Proto + value: '%REQ(:SCHEME)%' + - key: X-Forwarded-Host + value: '%REQ(:AUTHORITY)%' + - key: X-Forwarded-URI + value: '%REQ(:PATH)%' + - key: X-Forwarded-For + value: '%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%' + authorization_response: + allowed_upstream_headers: + patterns: + - prefix: remote- + allowed_client_headers: + patterns: + - exact: set-cookie + allowed_client_headers_on_success: + patterns: + - exact: set-cookie + failure_mode_allow: false + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: /etc/ssl/server.cert + private_key: + filename: /etc/ssl/server.key + clusters: + - name: authelia-frontend + transport_socket_matches: + - name: "enableTLS" + match: + enableTLS: true + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: {} + - name: "defaultTLSDisabled" + match: {} + transport_socket: + name: envoy.transport_sockets.raw_buffer + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.raw_buffer.v3.RawBuffer + connect_timeout: 0.25s + type: STRICT_DNS + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: authelia-frontend + endpoints: + - locality: + region: dev + priority: 0 + lb_endpoints: + - endpoint: + health_check_config: + hostname: authelia-frontend + port_value: 3000 + address: + socket_address: + address: authelia-frontend + port_value: 3000 + - locality: + region: ci + priority: 1 + lb_endpoints: + - endpoint: + address: + socket_address: + address: authelia-backend + port_value: 9091 + metadata: + filter_metadata: + envoy.transport_socket_match: + enableTLS: true + - name: authelia-backend + connect_timeout: 0.25s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: authelia-backend + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: authelia-backend + port_value: 9091 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: {} + - name: smtp + connect_timeout: 0.25s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: smtp + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: smtp + port_value: 1080 + - name: httpbin + connect_timeout: 0.25s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: httpbin + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: httpbin + port_value: 8000 + - name: nginx-backend + connect_timeout: 0.25s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: nginx-backend + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: nginx-backend + port_value: 80 +... diff --git a/internal/suites/suite_envoy.go b/internal/suites/suite_envoy.go new file mode 100644 index 000000000..9b9ff3916 --- /dev/null +++ b/internal/suites/suite_envoy.go @@ -0,0 +1,84 @@ +package suites + +import ( + "fmt" + "os" + "time" +) + +var envoySuiteName = "Envoy" + +func init() { + dockerEnvironment := NewDockerEnvironment([]string{ + "internal/suites/docker-compose.yml", + "internal/suites/Envoy/docker-compose.yml", + "internal/suites/example/compose/authelia/docker-compose.backend.{}.yml", + "internal/suites/example/compose/authelia/docker-compose.frontend.{}.yml", + "internal/suites/example/compose/nginx/backend/docker-compose.yml", + "internal/suites/example/compose/envoy/docker-compose.yml", + "internal/suites/example/compose/smtp/docker-compose.yml", + "internal/suites/example/compose/httpbin/docker-compose.yml", + }) + + if os.Getenv("CI") == t { + dockerEnvironment = NewDockerEnvironment([]string{ + "internal/suites/docker-compose.yml", + "internal/suites/Envoy/docker-compose.yml", + "internal/suites/example/compose/authelia/docker-compose.backend.{}.yml", + "internal/suites/example/compose/nginx/backend/docker-compose.yml", + "internal/suites/example/compose/envoy/docker-compose.yml", + "internal/suites/example/compose/smtp/docker-compose.yml", + "internal/suites/example/compose/httpbin/docker-compose.yml", + }) + } + + setup := func(suitePath string) error { + if err := dockerEnvironment.Up(); err != nil { + return err + } + + return waitUntilAutheliaIsReady(dockerEnvironment, envoySuiteName) + } + + displayAutheliaLogs := func() error { + backendLogs, err := dockerEnvironment.Logs("authelia-backend", nil) + if err != nil { + return err + } + + fmt.Println(backendLogs) + + if os.Getenv("CI") != t { + frontendLogs, err := dockerEnvironment.Logs("authelia-frontend", nil) + if err != nil { + return err + } + + fmt.Println(frontendLogs) + } + + envoyLogs, err := dockerEnvironment.Logs("envoy", nil) + if err != nil { + return err + } + + fmt.Println(envoyLogs) + + return nil + } + + teardown := func(suitePath string) error { + err := dockerEnvironment.Down() + return err + } + + GlobalRegistry.Register(envoySuiteName, Suite{ + SetUp: setup, + SetUpTimeout: 5 * time.Minute, + OnSetupTimeout: displayAutheliaLogs, + OnError: displayAutheliaLogs, + TestTimeout: 2 * time.Minute, + TearDown: teardown, + TearDownTimeout: 2 * time.Minute, + }) +} diff --git a/internal/suites/suite_envoy_test.go b/internal/suites/suite_envoy_test.go new file mode 100644 index 000000000..ca7cb1149 --- /dev/null +++ b/internal/suites/suite_envoy_test.go @@ -0,0 +1,39 @@ +package suites + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type EnvoySuite struct { + *RodSuite +} + +func NewEnvoySuite() *EnvoySuite { + return &EnvoySuite{RodSuite: new(RodSuite)} +} + +func (s *EnvoySuite) Test1FAScenario() { + suite.Run(s.T(), New1FAScenario()) +} + +func (s *EnvoySuite) Test2FAScenario() { + suite.Run(s.T(), New2FAScenario()) +} + +func (s *EnvoySuite) TestCustomHeaders() { + suite.Run(s.T(), NewCustomHeadersScenario()) +} + +func (s *EnvoySuite) TestResetPasswordScenario() { + suite.Run(s.T(), NewResetPasswordScenario()) +} + +func TestEnvoySuite(t *testing.T) { + if testing.Short() { + t.Skip("skipping suite test in short mode") + } + + suite.Run(t, NewEnvoySuite()) +}