From 428cda64d5522c8e2de8053174a3663d8bd96d20 Mon Sep 17 00:00:00 2001 From: Benjamin Foote Date: Fri, 27 Aug 2021 19:34:59 -0700 Subject: [PATCH] #373 set `vouch.document_root` for "vouch in a path" --- .defaults.yml | 1 + CHANGELOG.md | 4 + README.md | 41 ++++++++ config/config.yml_example | 3 + .../handler_login_url_document_root.yml | 17 ++++ handlers/auth.go | 5 +- handlers/auth_test.go | 95 +++++++++++++++++++ handlers/login.go | 2 +- handlers/login_test.go | 52 ++++++++++ main.go | 23 +++-- pkg/cfg/cfg.go | 1 + pkg/cfg/cfg_test.go | 4 +- pkg/responses/responses.go | 9 +- templates/index.tmpl | 12 +-- 14 files changed, 245 insertions(+), 24 deletions(-) create mode 100644 config/testing/handler_login_url_document_root.yml create mode 100644 handlers/auth_test.go diff --git a/.defaults.yml b/.defaults.yml index 55f74827..43b8bdf8 100644 --- a/.defaults.yml +++ b/.defaults.yml @@ -9,6 +9,7 @@ vouch: testing: false listen: 0.0.0.0 port: 9090 + # document_root: # domains: allowAllUsers: false publicAccess: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 769f6da2..6a88a96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Coming soon! Please document any work in progress here as part of your PR. It will be moved to the next tag when released. +## v0.33.0 + +- [Vouch Proxy running in a path](https://github.com/vouch/vouch-proxy/issues/373) + ## v0.32.0 - [slack oidc example](https://github.com/vouch/vouch-proxy/blob/master/config/config.yml_example_slack) and [slack app manifest](https://github.com/vouch/vouch-proxy/blob/master/examples/slack/vouch-slack-oidc-app-manifest.yml) diff --git a/README.md b/README.md index e4848305..e08dc1f1 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,47 @@ server { } ``` +### Vouch Proxy "in a path" + +As of `v0.33.0` Vouch Proxy can be served within an Nginx location (path) by configuring `vouch.document_root: /vp_in_a_path` + +This avoids the need to setup a separate domain for Vouch Proxy such as `vouch.yourdomain.com`. For example VP login will be served from https://protectedapp.yourdomain.com/vp_in_a_path/login + +```{.nginxconf} +server { + listen 443 ssl http2; + server_name protectedapp.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/protectedapp.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/protectedapp.yourdomain.com/privkey.pem; + + # This location serves all Vouch Proxy endpoints as /vp_in_a_path/$uri + # including /vp_in_a_path/validate, /vp_in_a_path/login, /vp_in_a_path/logout, /vp_in_a_path/auth, /vp_in_a_path/auth/$STATE, etc + location /vp_in_a_path { + proxy_pass http://127.0.0.1:9090; # must not! have a slash at the end + proxy_set_header Host $http_host; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + } + + # if /vp_in_a_path/validate returns `401 not authorized` then forward the request to the error401block + error_page 401 = @error401; + + location @error401 { + # redirect to Vouch Proxy for login + return 302 https://protectedapp.yourdomain.com/vp_in_a_path/login?url=$scheme://$http_host$request_uri&vouch-failcount=$auth_resp_failcount + } + + location / { + auth_request /vp_in_a_path/validate; + proxy_pass http://127.0.0.1:8080; + # see the Nginx config above for additional headers which can be set from Vouch Proxy + } + +``` + +### Additional Nginx Configurations + Additional Nginx configurations can be found in the [examples](https://github.com/vouch/vouch-proxy/tree/master/examples) directory. ## Configuring Vouch Proxy using Environmental Variables diff --git a/config/config.yml_example b/config/config.yml_example index a9eef07e..80bebd02 100644 --- a/config/config.yml_example +++ b/config/config.yml_example @@ -20,6 +20,9 @@ vouch: listen: 0.0.0.0 # VOUCH_LISTEN port: 9090 # VOUCH_PORT + # document_root - VOUCH_DOCUMENT_ROOT + # see README for " + # domains - VOUCH_DOMAINS # each of these domains must serve the url https://vouch.$domains[0] https://vouch.$domains[1] ... # so that the cookie which stores the JWT can be set in the relevant domain diff --git a/config/testing/handler_login_url_document_root.yml b/config/testing/handler_login_url_document_root.yml new file mode 100644 index 00000000..27be44bc --- /dev/null +++ b/config/testing/handler_login_url_document_root.yml @@ -0,0 +1,17 @@ +vouch: + document_root: /vouch_in_a_path + domains: + - example.com + + cookie: + secure: false + domain: example.com + + jwt: + secret: testingsecret + +oauth: + provider: google + client_id: http://vouch.github.io + auth_url: https://indielogin.com/auth + callback_url: http://vouch.github.io:9090/auth diff --git a/handlers/auth.go b/handlers/auth.go index 1594053a..dcfd69ef 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -42,10 +42,11 @@ func CallbackHandler(w http.ResponseWriter, r *http.Request) { responses.Error400(w, r, fmt.Errorf("/auth: could not find state in query %s", r.URL.RawQuery)) return } + // has to have a trailing / in its path, because the path of the session cookie is set to /auth/{state}/. - authStateURL := fmt.Sprintf("/auth/%s/?%s", queryState, r.URL.RawQuery) + // see note in login.go and https://github.com/vouch/vouch-proxy/issues/373 + authStateURL := fmt.Sprintf("%s/auth/%s/?%s", cfg.Cfg.DocumentRoot, queryState, r.URL.RawQuery) responses.Redirect302(w, r, authStateURL) - } // AuthStateHandler /auth/{state}/ diff --git a/handlers/auth_test.go b/handlers/auth_test.go new file mode 100644 index 00000000..2846c504 --- /dev/null +++ b/handlers/auth_test.go @@ -0,0 +1,95 @@ +/* + +Copyright 2020 The Vouch Proxy Authors. +Use of this source code is governed by The MIT License (MIT) that +can be found in the LICENSE file. Software distributed under The +MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +OR CONDITIONS OF ANY KIND, either express or implied. + +*/ + +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/vouch/vouch-proxy/pkg/cfg" +) + +func TestCallbackHandlerDocumentRoot(t *testing.T) { + handlerL := http.HandlerFunc(LoginHandler) + handlerA := http.HandlerFunc(CallbackHandler) + + tests := []struct { + name string + configFile string + wantcode int + }{ + {"should have URL that begins with DocumentRoot", "/config/testing/handler_login_url_document_root.yml", http.StatusFound}, + {"should have URL that does not begin with DocumentRoot", "/config/testing/handler_login_url.yml", http.StatusFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setUp(tt.configFile) + + // first make a request of /login to set the session cookie + reqLogin, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/login?url=http://myapp.example.com/logout", nil) + reqLogin.Header.Set("Host", "my.example.com") + if err != nil { + t.Fatal(err) + } + rrL := httptest.NewRecorder() + handlerL.ServeHTTP(rrL, reqLogin) + + // grab the state from the session cookie to + session, err := sessstore.Get(reqLogin, cfg.Cfg.Session.Name) + state := session.Values["state"].(string) + + // now mimic an IdP returning the state variable back to us + reqAuth, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/auth?state="+state, nil) + reqAuth.Header.Set("Host", "my.example.com") + if err != nil { + t.Fatal(err) + } + // transfer the cookie from rrL to reqAuth + rrA := httptest.NewRecorder() + + handlerA.ServeHTTP(rrA, reqAuth) + if rrA.Code != tt.wantcode { + t.Errorf("LoginHandler() status = %v, want %v", rrA.Code, tt.wantcode) + } + + // confirm the requst to $DocumentRoot/auth is redirected to $DocumentRoot/auth/$state + redirectURL, err := url.Parse(rrA.Header()["Location"][0]) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, fmt.Sprintf("%s/auth/%s/", cfg.Cfg.DocumentRoot, state), redirectURL.Path) + + }) + } +} + +func TestAuthStateHandler(t *testing.T) { + type args struct { + w http.ResponseWriter + r *http.Request + } + tests := []struct { + name string + args args + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + AuthStateHandler(tt.args.w, tt.args.r) + }) + } +} diff --git a/handlers/login.go b/handlers/login.go index a7ff8f3b..462a4f7f 100644 --- a/handlers/login.go +++ b/handlers/login.go @@ -55,7 +55,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { // set the path for the session cookie to only send the correct cookie to /auth/{state}/ // must have a trailing slash. Otherwise, it is send to all endpoints that _start_ with the cookie path. - session.Options.Path = fmt.Sprintf("/auth/%s/", state) + session.Options.Path = fmt.Sprintf("%s/auth/%s/", cfg.Cfg.DocumentRoot, state) log.Debugf("session state set to %s", session.Values["state"]) diff --git a/handlers/login_test.go b/handlers/login_test.go index 240e1a4a..71f519a9 100644 --- a/handlers/login_test.go +++ b/handlers/login_test.go @@ -14,6 +14,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -128,6 +129,57 @@ func Test_getValidRequestedURL(t *testing.T) { } } +func TestLoginHandlerDocumentRoot(t *testing.T) { + handler := http.HandlerFunc(LoginHandler) + + tests := []struct { + name string + configFile string + wantcode int + }{ + {"general test", "/config/testing/handler_login_url_document_root.yml", http.StatusFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setUp(tt.configFile) + + req, err := http.NewRequest("GET", cfg.Cfg.DocumentRoot+"/logout?url=http://myapp.example.com/login", nil) + req.Header.Set("Host", "my.example.com") + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != tt.wantcode { + t.Errorf("LogoutHandler() status = %v, want %v", rr.Code, tt.wantcode) + } + + found := false + for _, c := range rr.Result().Cookies() { + if c.Name == cfg.Cfg.Session.Name { + if strings.HasPrefix(c.Path, cfg.Cfg.DocumentRoot+"/auth") { + found = true + } + } + } + if !found { + t.Errorf("session cookie is not set into path that begins with Cfg.DocumentRoot %s", cfg.Cfg.DocumentRoot) + } + + // confirm the OAuthClient has a properly configured + redirectURL, err := url.Parse(rr.Header()["Location"][0]) + if err != nil { + t.Fatal(err) + } + redirectParam := redirectURL.Query().Get("redirect_uri") + assert.NotEmpty(t, cfg.OAuthClient.RedirectURL, "cfg.OAuthClient.RedirectURL is empty") + assert.NotEmpty(t, redirectParam, "redirect_uri should not be empty when redirected to google oauth") + + }) + } +} func TestLoginHandler(t *testing.T) { handler := http.HandlerFunc(LoginHandler) diff --git a/main.go b/main.go index 965e7603..d5e481f9 100644 --- a/main.go +++ b/main.go @@ -147,26 +147,31 @@ func main() { "semver", semver, "listen", scheme[tls]+"://"+listen, "tls", tls, + "document_root", cfg.Cfg.DocumentRoot, "oauth.provider", cfg.GenOAuth.Provider) // router := mux.NewRouter() router := httprouter.New() + if cfg.Cfg.DocumentRoot != "" { + logger.Debugf("adjusting all served URIs to be under %s", cfg.Cfg.DocumentRoot) + } + authH := http.HandlerFunc(handlers.ValidateRequestHandler) - router.HandlerFunc(http.MethodGet, "/validate", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH))) - router.HandlerFunc(http.MethodGet, "/_external-auth-:id", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH))) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/validate", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH))) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/_external-auth-:id", timelog.TimeLog(jwtmanager.JWTCacheHandler(authH))) loginH := http.HandlerFunc(handlers.LoginHandler) - router.HandlerFunc(http.MethodGet, "/login", timelog.TimeLog(loginH)) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/login", timelog.TimeLog(loginH)) logoutH := http.HandlerFunc(handlers.LogoutHandler) - router.HandlerFunc(http.MethodGet, "/logout", timelog.TimeLog(logoutH)) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/logout", timelog.TimeLog(logoutH)) callH := http.HandlerFunc(handlers.CallbackHandler) - router.HandlerFunc(http.MethodGet, "/auth/", timelog.TimeLog(callH)) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/auth", timelog.TimeLog(callH)) authStateH := http.HandlerFunc(handlers.AuthStateHandler) - router.HandlerFunc(http.MethodGet, "/auth/:state/", timelog.TimeLog(authStateH)) + router.HandlerFunc(http.MethodGet, cfg.Cfg.DocumentRoot+"/auth/:state/", timelog.TimeLog(authStateH)) healthH := http.HandlerFunc(handlers.HealthcheckHandler) router.HandlerFunc(http.MethodGet, "/healthcheck", timelog.TimeLog(healthH)) @@ -175,9 +180,9 @@ func main() { // router.ServeFiles("/static/*filepath", http.FS(staticFs)) // so instead we publish all three routes - router.Handler(http.MethodGet, "/static/css/main.css", http.FileServer(http.FS(staticFs))) - router.Handler(http.MethodGet, "/static/img/favicon.ico", http.FileServer(http.FS(staticFs))) - router.Handler(http.MethodGet, "/static/img/multicolor_V_500x500.png", http.FileServer(http.FS(staticFs))) + router.Handler(http.MethodGet, cfg.Cfg.DocumentRoot+"/static/css/main.css", http.StripPrefix(cfg.Cfg.DocumentRoot, http.FileServer(http.FS(staticFs)))) + router.Handler(http.MethodGet, cfg.Cfg.DocumentRoot+"/static/img/favicon.ico", http.StripPrefix(cfg.Cfg.DocumentRoot, http.FileServer(http.FS(staticFs)))) + router.Handler(http.MethodGet, cfg.Cfg.DocumentRoot+"/static/img/multicolor_V_500x500.png", http.StripPrefix(cfg.Cfg.DocumentRoot, http.FileServer(http.FS(staticFs)))) // this also works for static files // router.NotFound = http.FileServer(http.FS(staticFs)) diff --git a/pkg/cfg/cfg.go b/pkg/cfg/cfg.go index 6a5ad5ec..d462f207 100644 --- a/pkg/cfg/cfg.go +++ b/pkg/cfg/cfg.go @@ -44,6 +44,7 @@ type Config struct { LogLevel string `mapstructure:"logLevel"` Listen string `mapstructure:"listen"` Port int `mapstructure:"port"` + DocumentRoot string `mapstructure:"document_root" envconfig:"document_root"` Domains []string `mapstructure:"domains"` WhiteList []string `mapstructure:"whitelist"` TeamWhiteList []string `mapstructure:"teamWhitelist"` diff --git a/pkg/cfg/cfg_test.go b/pkg/cfg/cfg_test.go index 5b10f401..dc7ffb74 100644 --- a/pkg/cfg/cfg_test.go +++ b/pkg/cfg/cfg_test.go @@ -134,7 +134,7 @@ func Test_configureFromEnvCfg(t *testing.T) { senv := []string{"VOUCH_LISTEN", "VOUCH_JWT_ISSUER", "VOUCH_JWT_SECRET", "VOUCH_HEADERS_JWT", "VOUCH_HEADERS_USER", "VOUCH_HEADERS_QUERYSTRING", "VOUCH_HEADERS_REDIRECT", "VOUCH_HEADERS_SUCCESS", "VOUCH_HEADERS_ERROR", "VOUCH_HEADERS_CLAIMHEADER", "VOUCH_HEADERS_ACCESSTOKEN", "VOUCH_HEADERS_IDTOKEN", "VOUCH_COOKIE_NAME", "VOUCH_COOKIE_DOMAIN", - "VOUCH_COOKIE_SAMESITE", "VOUCH_TESTURL", "VOUCH_SESSION_NAME", "VOUCH_SESSION_KEY"} + "VOUCH_COOKIE_SAMESITE", "VOUCH_TESTURL", "VOUCH_SESSION_NAME", "VOUCH_SESSION_KEY", "VOUCH_DOCUMENT_ROOT"} // array of strings saenv := []string{"VOUCH_DOMAINS", "VOUCH_WHITELIST", "VOUCH_TEAMWHITELIST", "VOUCH_HEADERS_CLAIMS", "VOUCH_TESTURLS", "VOUCH_POST_LOGOUT_REDIRECT_URIS"} // int @@ -171,7 +171,7 @@ func Test_configureFromEnvCfg(t *testing.T) { scfg := []string{Cfg.Listen, Cfg.JWT.Issuer, Cfg.JWT.Secret, Cfg.Headers.JWT, Cfg.Headers.User, Cfg.Headers.QueryString, Cfg.Headers.Redirect, Cfg.Headers.Success, Cfg.Headers.Error, Cfg.Headers.ClaimHeader, Cfg.Headers.AccessToken, Cfg.Headers.IDToken, Cfg.Cookie.Name, Cfg.Cookie.Domain, - Cfg.Cookie.SameSite, Cfg.TestURL, Cfg.Session.Name, Cfg.Session.Key, + Cfg.Cookie.SameSite, Cfg.TestURL, Cfg.Session.Name, Cfg.Session.Key, Cfg.DocumentRoot, } sacfg := [][]string{Cfg.Domains, Cfg.WhiteList, Cfg.TeamWhiteList, Cfg.Headers.Claims, Cfg.TestURLs, Cfg.LogoutRedirectURLs} diff --git a/pkg/responses/responses.go b/pkg/responses/responses.go index 2ff29f3e..0373964b 100644 --- a/pkg/responses/responses.go +++ b/pkg/responses/responses.go @@ -23,9 +23,10 @@ import ( // Index variables passed to index.tmpl type Index struct { - Msg string - TestURLs []string - Testing bool + Msg string + TestURLs []string + Testing bool + DocumentRoot string } var ( @@ -48,7 +49,7 @@ func Configure() { // RenderIndex render the response as an HTML page, mostly used in testing func RenderIndex(w http.ResponseWriter, msg string) { - if err := indexTemplate.Execute(w, &Index{Msg: msg, TestURLs: cfg.Cfg.TestURLs, Testing: cfg.Cfg.Testing}); err != nil { + if err := indexTemplate.Execute(w, &Index{Msg: msg, TestURLs: cfg.Cfg.TestURLs, Testing: cfg.Cfg.Testing, DocumentRoot: cfg.Cfg.DocumentRoot}); err != nil { log.Error(err) } } diff --git a/templates/index.tmpl b/templates/index.tmpl index dbfb4197..a8571b22 100644 --- a/templates/index.tmpl +++ b/templates/index.tmpl @@ -1,8 +1,8 @@ - - + + @@ -14,7 +14,7 @@
- + Vouch Proxy
@@ -30,9 +30,9 @@ All 302 redirects will be captured and presented as links here