diff --git a/pkg/apis/options/app.go b/pkg/apis/options/app.go new file mode 100644 index 00000000..76c4f84a --- /dev/null +++ b/pkg/apis/options/app.go @@ -0,0 +1,50 @@ +package options + +import "github.com/spf13/pflag" + +// Templates includes options for configuring the sign in and error pages +// appearance. +type Templates struct { + // Path is the path to a folder containing a sign_in.html and an error.html + // template. + // These files will be used instead of the default templates if present. + // If either file is missing, the default will be used instead. + Path string `flag:"custom-templates-dir" cfg:"custom_templates_dir"` + + // Banner overides the default sign_in page banner text. If unspecified, + // the message will give users a list of allowed email domains. + Banner string `flag:"banner" cfg:"banner"` + + // Footer overrides the default sign_in page footer text. + Footer string `flag:"footer" cfg:"footer"` + + // DisplayLoginForm determines whether the sign_in page should render a + // password form if a static passwords file (htpasswd file) has been + // configured. + DisplayLoginForm bool `flag:"display-htpasswd-form" cfg:"display_htpasswd_form"` + + // Debug renders detailed errors when an error page is shown. + // It is not advised to use this in production as errors may contain sensitive + // information. + // Use only for diagnosing backend errors. + Debug bool `flag:"show-debug-on-error" cfg:"show-debug-on-error"` +} + +func templatesFlagSet() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("templates", pflag.ExitOnError) + + flagSet.String("custom-templates-dir", "", "path to custom html templates") + flagSet.String("banner", "", "custom banner string. Use \"-\" to disable default banner.") + flagSet.String("footer", "", "custom footer string. Use \"-\" to disable default footer.") + flagSet.Bool("display-htpasswd-form", true, "display username / password login form if an htpasswd file is provided") + flagSet.Bool("show-debug-on-error", false, "show detailed error information on error pages (WARNING: this may contain sensitive information - do not use in production)") + + return flagSet +} + +// templatesDefaults creates a Templates and populates it with any default values +func templatesDefaults() Templates { + return Templates{ + DisplayLoginForm: true, + } +} diff --git a/pkg/app/pagewriter/error_page.go b/pkg/app/pagewriter/error_page.go new file mode 100644 index 00000000..28d81bb1 --- /dev/null +++ b/pkg/app/pagewriter/error_page.go @@ -0,0 +1,97 @@ +package pagewriter + +import ( + "fmt" + "html/template" + "net/http" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" +) + +// errorMessages are default error messages for each of the the different +// http status codes expected to be rendered in the error page. +var errorMessages = map[int]string{ + http.StatusInternalServerError: "Oops! Something went wrong. For more information contact your server administrator.", + http.StatusNotFound: "We could not find the resource you were looking for.", + http.StatusForbidden: "You do not have permission to access this resource.", + http.StatusUnauthorized: "You need to be logged in to access this resource.", +} + +// errorPageWriter is used to render error pages. +type errorPageWriter struct { + // template is the error page HTML template. + template *template.Template + + // proxyPrefix is the prefix under which OAuth2 Proxy pages are served. + proxyPrefix string + + // footer is the footer to be displayed at the bottom of the page. + // If not set, a default footer will be used. + footer string + + // version is the OAuth2 Proxy version to be used in the default footer. + version string + + // debug determines whether errors pages should be rendered with detailed + // errors. + debug bool +} + +// WriteErrorPage writes an error page to the given response writer. +// It uses the passed redirectURL to give users the option to go back to where +// they originally came from or try signing in again. +func (e *errorPageWriter) WriteErrorPage(rw http.ResponseWriter, status int, redirectURL string, appError string, messages ...interface{}) { + rw.WriteHeader(status) + + // We allow unescaped template.HTML since it is user configured options + /* #nosec G203 */ + data := struct { + Title string + Message string + ProxyPrefix string + StatusCode int + Redirect string + Footer template.HTML + Version string + }{ + Title: http.StatusText(status), + Message: e.getMessage(status, appError, messages...), + ProxyPrefix: e.proxyPrefix, + StatusCode: status, + Redirect: redirectURL, + Footer: template.HTML(e.footer), + Version: e.version, + } + + if err := e.template.Execute(rw, data); err != nil { + logger.Printf("Error rendering error template: %v", err) + http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} + +// ProxyErrorHandler is used by the upstream ReverseProxy to render error pages +// when there are issues with upstream servers. +// It is expected to always render a bad gateway error. +func (e *errorPageWriter) ProxyErrorHandler(rw http.ResponseWriter, req *http.Request, proxyErr error) { + logger.Errorf("Error proxying to upstream server: %v", proxyErr) + e.WriteErrorPage(rw, http.StatusBadGateway, "", proxyErr.Error(), "There was a problem connecting to the upstream server.") +} + +// getMessage creates the message for the template parameters. +// If the errorPagewriter.Debug is enabled, the application error takes precedence. +// Otherwise, any messages will be used. +// The first message is expected to be a format string. +// If no messages are supplied, a default error message will be used. +func (e *errorPageWriter) getMessage(status int, appError string, messages ...interface{}) string { + if e.debug { + return appError + } + if len(messages) > 0 { + format := fmt.Sprintf("%v", messages[0]) + return fmt.Sprintf(format, messages[1:]...) + } + if msg, ok := errorMessages[status]; ok { + return msg + } + return "Unknown error" +} diff --git a/pkg/app/pagewriter/error_page_test.go b/pkg/app/pagewriter/error_page_test.go new file mode 100644 index 00000000..56cd821b --- /dev/null +++ b/pkg/app/pagewriter/error_page_test.go @@ -0,0 +1,101 @@ +package pagewriter + +import ( + "errors" + "html/template" + "io/ioutil" + "net/http/httptest" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Error Page Writer", func() { + var errorPage *errorPageWriter + + BeforeEach(func() { + tmpl, err := template.New("").Parse("{{.Title}} {{.Message}} {{.ProxyPrefix}} {{.StatusCode}} {{.Redirect}} {{.Footer}} {{.Version}}") + Expect(err).ToNot(HaveOccurred()) + + errorPage = &errorPageWriter{ + template: tmpl, + proxyPrefix: "/prefix/", + footer: "Custom Footer Text", + version: "v0.0.0-test", + } + }) + + Context("WriteErrorPage", func() { + It("Writes the template to the response writer", func() { + recorder := httptest.NewRecorder() + errorPage.WriteErrorPage(recorder, 403, "/redirect", "Access Denied") + + body, err := ioutil.ReadAll(recorder.Result().Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal("Forbidden You do not have permission to access this resource. /prefix/ 403 /redirect Custom Footer Text v0.0.0-test")) + }) + + It("With a different code, uses the stock message for the correct code", func() { + recorder := httptest.NewRecorder() + errorPage.WriteErrorPage(recorder, 500, "/redirect", "Access Denied") + + body, err := ioutil.ReadAll(recorder.Result().Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal("Internal Server Error Oops! Something went wrong. For more information contact your server administrator. /prefix/ 500 /redirect Custom Footer Text v0.0.0-test")) + }) + + It("With a message override, uses the message", func() { + recorder := httptest.NewRecorder() + errorPage.WriteErrorPage(recorder, 403, "/redirect", "Access Denied", "An extra message: %s", "with more context.") + + body, err := ioutil.ReadAll(recorder.Result().Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal("Forbidden An extra message: with more context. /prefix/ 403 /redirect Custom Footer Text v0.0.0-test")) + }) + }) + + Context("ProxyErrorHandler", func() { + It("Writes a bad gateway error the response writer", func() { + req := httptest.NewRequest("", "/bad-gateway", nil) + recorder := httptest.NewRecorder() + errorPage.ProxyErrorHandler(recorder, req, errors.New("some upstream error")) + + body, err := ioutil.ReadAll(recorder.Result().Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal("Bad Gateway There was a problem connecting to the upstream server. /prefix/ 502 Custom Footer Text v0.0.0-test")) + }) + }) + + Context("With Debug enabled", func() { + BeforeEach(func() { + tmpl, err := template.New("").Parse("{{.Message}}") + Expect(err).ToNot(HaveOccurred()) + + errorPage.template = tmpl + errorPage.debug = true + }) + + Context("WriteErrorPage", func() { + It("Writes the detailed error in place of the message", func() { + recorder := httptest.NewRecorder() + errorPage.WriteErrorPage(recorder, 403, "/redirect", "Debug error") + + body, err := ioutil.ReadAll(recorder.Result().Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal("Debug error")) + }) + }) + + Context("ProxyErrorHandler", func() { + It("Writes a bad gateway error the response writer", func() { + req := httptest.NewRequest("", "/bad-gateway", nil) + recorder := httptest.NewRecorder() + errorPage.ProxyErrorHandler(recorder, req, errors.New("some upstream error")) + + body, err := ioutil.ReadAll(recorder.Result().Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal("some upstream error")) + }) + }) + }) +}) diff --git a/pkg/app/pagewriter/pagewriter.go b/pkg/app/pagewriter/pagewriter.go new file mode 100644 index 00000000..fdc8ec30 --- /dev/null +++ b/pkg/app/pagewriter/pagewriter.go @@ -0,0 +1,85 @@ +package pagewriter + +import ( + "fmt" + "net/http" +) + +// Writer is an interface for rendering html templates for both sign-in and +// error pages. +// It can also be used to write errors for the http.ReverseProxy used in the +// upstream package. +type Writer interface { + WriteSignInPage(rw http.ResponseWriter, redirectURL string) + WriteErrorPage(rw http.ResponseWriter, status int, redirectURL string, appError string, messages ...interface{}) + ProxyErrorHandler(rw http.ResponseWriter, req *http.Request, proxyErr error) +} + +// pageWriter implements the Writer interface +type pageWriter struct { + *errorPageWriter + *signInPageWriter +} + +// Opts contains all options required to configure the template +// rendering within OAuth2 Proxy. +type Opts struct { + // TemplatesPath is the path from which to load custom templates for the sign-in and error pages. + TemplatesPath string + + // ProxyPrefix is the prefix under which OAuth2 Proxy pages are served. + ProxyPrefix string + + // Footer is the footer to be displayed at the bottom of the page. + // If not set, a default footer will be used. + Footer string + + // Version is the OAuth2 Proxy version to be used in the default footer. + Version string + + // Debug determines whether errors pages should be rendered with detailed + // errors. + Debug bool + + // DisplayLoginForm determines whether or not the basic auth password form is displayed on the sign-in page. + DisplayLoginForm bool + + // ProviderName is the name of the provider that should be displayed on the login button. + ProviderName string + + // SignInMessage is the messge displayed above the login button. + SignInMessage string +} + +// NewWriter constructs a Writer from the options given to allow +// rendering of sign-in and error pages. +func NewWriter(opts Opts) (Writer, error) { + templates, err := loadTemplates(opts.TemplatesPath) + if err != nil { + return nil, fmt.Errorf("error loading templates: %v", err) + } + + errorPage := &errorPageWriter{ + template: templates.Lookup("error.html"), + proxyPrefix: opts.ProxyPrefix, + footer: opts.Footer, + version: opts.Version, + debug: opts.Debug, + } + + signInPage := &signInPageWriter{ + template: templates.Lookup("sign_in.html"), + errorPageWriter: errorPage, + proxyPrefix: opts.ProxyPrefix, + providerName: opts.ProviderName, + signInMessage: opts.SignInMessage, + footer: opts.Footer, + version: opts.Version, + displayLoginForm: opts.DisplayLoginForm, + } + + return &pageWriter{ + errorPageWriter: errorPage, + signInPageWriter: signInPage, + }, nil +} diff --git a/pkg/app/pagewriter/pagewriter_suite_test.go b/pkg/app/pagewriter/pagewriter_suite_test.go new file mode 100644 index 00000000..ade6a94b --- /dev/null +++ b/pkg/app/pagewriter/pagewriter_suite_test.go @@ -0,0 +1,17 @@ +package pagewriter + +import ( + "testing" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestOptionsSuite(t *testing.T) { + logger.SetOutput(GinkgoWriter) + logger.SetErrOutput(GinkgoWriter) + + RegisterFailHandler(Fail) + RunSpecs(t, "App Suite") +} diff --git a/pkg/app/pagewriter/pagewriter_test.go b/pkg/app/pagewriter/pagewriter_test.go new file mode 100644 index 00000000..3d7669f9 --- /dev/null +++ b/pkg/app/pagewriter/pagewriter_test.go @@ -0,0 +1,126 @@ +package pagewriter + +import ( + "io/ioutil" + "net/http/httptest" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Writer", func() { + Context("NewWriter", func() { + var writer Writer + var opts Opts + + BeforeEach(func() { + opts = Opts{ + TemplatesPath: "", + ProxyPrefix: "/prefix", + Footer: "