diff --git a/config.template.yml b/config.template.yml index d209618f1..b327433cd 100644 --- a/config.template.yml +++ b/config.template.yml @@ -16,6 +16,8 @@ server: read_buffer_size: 4096 # Write buffer size configures the http server's maximum outgoing response size in bytes. write_buffer_size: 4096 + # Set the single level path Authelia listens on, must be alphanumeric chars and should not contain any slashes. + path: "" # Level of verbosity for logs: info, debug, trace log_level: debug diff --git a/docs/configuration/server.md b/docs/configuration/server.md index e984d8d09..2b84da085 100644 --- a/docs/configuration/server.md +++ b/docs/configuration/server.md @@ -20,6 +20,8 @@ server: read_buffer_size: 4096 # Write buffer size configures the http server's maximum outgoing response size in bytes. write_buffer_size: 4096 + # Set the single level path Authelia listens on, must be alphanumeric chars and should not contain any slashes. + path: "" ``` ### Buffer Sizes @@ -27,3 +29,23 @@ server: The read and write buffer sizes generally should be the same. This is because when Authelia verifies if the user is authorized to visit a URL, it also sends back nearly the same size response (write_buffer_size) as the request (read_buffer_size). + +### Path + +Authelia by default is served from the root `/` location, either via its own domain or subdomain. + +Example: https://auth.example.com/, https://example.com/ +```yaml +server: + path: "" +``` + +Modifying this setting will allow you to serve Authelia out from a specified base path. Please note +that currently only a single level path is supported meaning slashes are not allowed, and only +alphanumeric characters are supported. + +Example: https://auth.example.com/authelia/, https://example.com/authelia/ +```yaml +server: + path: authelia +``` \ No newline at end of file diff --git a/internal/configuration/schema/server.go b/internal/configuration/schema/server.go index 2dfe29fcd..8858d3834 100644 --- a/internal/configuration/schema/server.go +++ b/internal/configuration/schema/server.go @@ -2,8 +2,9 @@ package schema // ServerConfiguration represents the configuration of the http server. type ServerConfiguration struct { - ReadBufferSize int `mapstructure:"read_buffer_size"` - WriteBufferSize int `mapstructure:"write_buffer_size"` + Path string `mapstructure:"path"` + ReadBufferSize int `mapstructure:"read_buffer_size"` + WriteBufferSize int `mapstructure:"write_buffer_size"` } // DefaultServerConfiguration represents the default values of the ServerConfiguration. diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 8874d9dcb..f58836024 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -14,6 +14,7 @@ var validKeys = []string{ // Server Keys. "server.read_buffer_size", "server.write_buffer_size", + "server.path", // TOTP Keys. "totp.issuer", diff --git a/internal/configuration/validator/server.go b/internal/configuration/validator/server.go index 5c6f670ca..14c4302d4 100644 --- a/internal/configuration/validator/server.go +++ b/internal/configuration/validator/server.go @@ -2,8 +2,11 @@ package validator import ( "fmt" + "path" + "strings" "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/utils" ) var defaultReadBufferSize = 4096 @@ -11,6 +14,16 @@ var defaultWriteBufferSize = 4096 // ValidateServer checks a server configuration is correct. func ValidateServer(configuration *schema.ServerConfiguration, validator *schema.StructValidator) { + switch { + case strings.Contains(configuration.Path, "/"): + validator.Push(fmt.Errorf("server path must not contain any forward slashes")) + case !utils.IsStringAlphaNumeric(configuration.Path): + validator.Push(fmt.Errorf("server path must only be alpha numeric characters")) + case configuration.Path == "": // Don't do anything if it's blank. + default: + configuration.Path = path.Clean("/" + configuration.Path) + } + if configuration.ReadBufferSize == 0 { configuration.ReadBufferSize = defaultReadBufferSize } else if configuration.ReadBufferSize < 0 { diff --git a/internal/configuration/validator/server_test.go b/internal/configuration/validator/server_test.go index 2bd10577c..dee5b5746 100644 --- a/internal/configuration/validator/server_test.go +++ b/internal/configuration/validator/server_test.go @@ -12,9 +12,7 @@ import ( func TestShouldSetDefaultConfig(t *testing.T) { validator := schema.NewStructValidator() config := schema.ServerConfiguration{} - ValidateServer(&config, validator) - require.Len(t, validator.Errors(), 0) assert.Equal(t, defaultReadBufferSize, config.ReadBufferSize) assert.Equal(t, defaultWriteBufferSize, config.WriteBufferSize) @@ -26,10 +24,28 @@ func TestShouldRaiseOnNegativeValues(t *testing.T) { ReadBufferSize: -1, WriteBufferSize: -1, } - ValidateServer(&config, validator) - require.Len(t, validator.Errors(), 2) assert.EqualError(t, validator.Errors()[0], "server read buffer size must be above 0") assert.EqualError(t, validator.Errors()[1], "server write buffer size must be above 0") } + +func TestShouldRaiseOnNonAlphanumericCharsInPath(t *testing.T) { + validator := schema.NewStructValidator() + config := schema.ServerConfiguration{ + Path: "app le", + } + ValidateServer(&config, validator) + require.Len(t, validator.Errors(), 1) + assert.Error(t, validator.Errors()[0], "server path must only be alpha numeric characters") +} + +func TestShouldRaiseOnForwardSlashInPath(t *testing.T) { + validator := schema.NewStructValidator() + config := schema.ServerConfiguration{ + Path: "app/le", + } + ValidateServer(&config, validator) + assert.Len(t, validator.Errors(), 1) + assert.Error(t, validator.Errors()[0], "server path must not contain any forward slashes") +} diff --git a/internal/middlewares/strip_path.go b/internal/middlewares/strip_path.go new file mode 100644 index 000000000..7d82ed271 --- /dev/null +++ b/internal/middlewares/strip_path.go @@ -0,0 +1,22 @@ +package middlewares + +import ( + "bytes" + + "github.com/valyala/fasthttp" +) + +// StripPathMiddleware strips the first level of a path. +func StripPathMiddleware(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + uri := ctx.Request.RequestURI() + n := bytes.IndexByte(uri[1:], '/') + + if n >= 0 { + uri = uri[n+1:] + ctx.Request.SetRequestURI(string(uri)) + } + + next(ctx) + } +} diff --git a/internal/server/index.go b/internal/server/index.go index 0db06fc01..39a8875d1 100644 --- a/internal/server/index.go +++ b/internal/server/index.go @@ -2,8 +2,8 @@ package server import ( "fmt" - "html/template" "io/ioutil" + "text/template" "github.com/valyala/fasthttp" @@ -16,7 +16,7 @@ var alphaNumericRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV // ServeIndex serve the index.html file with nonce generated for supporting // restrictive CSP while using material-ui from the embedded virtual filesystem. //go:generate broccoli -src ../../public_html -o public_html -func ServeIndex(publicDir string) fasthttp.RequestHandler { +func ServeIndex(publicDir, base string) fasthttp.RequestHandler { f, err := br.Open(publicDir + "/index.html") if err != nil { logging.Logger().Fatalf("Unable to open index.html: %v", err) @@ -36,9 +36,9 @@ func ServeIndex(publicDir string) fasthttp.RequestHandler { nonce := utils.RandomString(32, alphaNumericRunes) ctx.SetContentType("text/html; charset=utf-8") - ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("default-src 'self'; style-src 'self' 'nonce-%s'", nonce)) + ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("default-src 'self'; object-src 'none'; require-trusted-types-for 'script'; style-src 'self' 'nonce-%s'", nonce)) - err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ CSPNonce string }{CSPNonce: nonce}) + err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ CSPNonce, Base string }{CSPNonce: nonce, Base: base}) if err != nil { ctx.Error("An error occurred", 503) logging.Logger().Errorf("Unable to execute template: %v", err) diff --git a/internal/server/server.go b/internal/server/server.go index f3c290bab..3f1884903 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -23,74 +23,74 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi autheliaMiddleware := middlewares.AutheliaMiddleware(configuration, providers) embeddedAssets := "/public_html" rootFiles := []string{"favicon.ico", "manifest.json", "robots.txt"} + // TODO: Remove in v4.18.0. if os.Getenv("PUBLIC_DIR") != "" { logging.Logger().Warn("PUBLIC_DIR environment variable has been deprecated, assets are now embedded.") } - router := router.New() - - router.GET("/", ServeIndex(embeddedAssets)) + r := router.New() + r.GET("/", ServeIndex(embeddedAssets, configuration.Server.Path)) for _, f := range rootFiles { - router.GET("/"+f, fasthttpadaptor.NewFastHTTPHandler(br.Serve(embeddedAssets))) + r.GET("/"+f, fasthttpadaptor.NewFastHTTPHandler(br.Serve(embeddedAssets))) } - router.GET("/static/{filepath:*}", fasthttpadaptor.NewFastHTTPHandler(br.Serve(embeddedAssets))) + r.GET("/static/{filepath:*}", fasthttpadaptor.NewFastHTTPHandler(br.Serve(embeddedAssets))) - router.GET("/api/state", autheliaMiddleware(handlers.StateGet)) + r.GET("/api/state", autheliaMiddleware(handlers.StateGet)) - router.GET("/api/configuration", autheliaMiddleware(handlers.ConfigurationGet)) - router.GET("/api/configuration/extended", autheliaMiddleware( + r.GET("/api/configuration", autheliaMiddleware(handlers.ConfigurationGet)) + r.GET("/api/configuration/extended", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.ExtendedConfigurationGet))) - router.GET("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend))) - router.HEAD("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend))) + r.GET("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend))) + r.HEAD("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend))) - router.POST("/api/firstfactor", autheliaMiddleware(handlers.FirstFactorPost(1000, true))) - router.POST("/api/logout", autheliaMiddleware(handlers.LogoutPost)) + r.POST("/api/firstfactor", autheliaMiddleware(handlers.FirstFactorPost(1000, true))) + r.POST("/api/logout", autheliaMiddleware(handlers.LogoutPost)) // Only register endpoints if forgot password is not disabled. if !configuration.AuthenticationBackend.DisableResetPassword { // Password reset related endpoints. - router.POST("/api/reset-password/identity/start", autheliaMiddleware( + r.POST("/api/reset-password/identity/start", autheliaMiddleware( handlers.ResetPasswordIdentityStart)) - router.POST("/api/reset-password/identity/finish", autheliaMiddleware( + r.POST("/api/reset-password/identity/finish", autheliaMiddleware( handlers.ResetPasswordIdentityFinish)) - router.POST("/api/reset-password", autheliaMiddleware( + r.POST("/api/reset-password", autheliaMiddleware( handlers.ResetPasswordPost)) } // Information about the user. - router.GET("/api/user/info", autheliaMiddleware( + r.GET("/api/user/info", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.UserInfoGet))) - router.POST("/api/user/info/2fa_method", autheliaMiddleware( + r.POST("/api/user/info/2fa_method", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.MethodPreferencePost))) // TOTP related endpoints. - router.POST("/api/secondfactor/totp/identity/start", autheliaMiddleware( + r.POST("/api/secondfactor/totp/identity/start", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityStart))) - router.POST("/api/secondfactor/totp/identity/finish", autheliaMiddleware( + r.POST("/api/secondfactor/totp/identity/finish", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityFinish))) - router.POST("/api/secondfactor/totp", autheliaMiddleware( + r.POST("/api/secondfactor/totp", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.SecondFactorTOTPPost(&handlers.TOTPVerifierImpl{ Period: uint(configuration.TOTP.Period), Skew: uint(*configuration.TOTP.Skew), })))) // U2F related endpoints. - router.POST("/api/secondfactor/u2f/identity/start", autheliaMiddleware( + r.POST("/api/secondfactor/u2f/identity/start", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.SecondFactorU2FIdentityStart))) - router.POST("/api/secondfactor/u2f/identity/finish", autheliaMiddleware( + r.POST("/api/secondfactor/u2f/identity/finish", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.SecondFactorU2FIdentityFinish))) - router.POST("/api/secondfactor/u2f/register", autheliaMiddleware( + r.POST("/api/secondfactor/u2f/register", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.SecondFactorU2FRegister))) - router.POST("/api/secondfactor/u2f/sign_request", autheliaMiddleware( + r.POST("/api/secondfactor/u2f/sign_request", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.SecondFactorU2FSignGet))) - router.POST("/api/secondfactor/u2f/sign", autheliaMiddleware( + r.POST("/api/secondfactor/u2f/sign", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.SecondFactorU2FSignPost(&handlers.U2FVerifierImpl{})))) // Configure DUO api endpoint only if configuration exists. @@ -108,21 +108,26 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi configuration.DuoAPI.Hostname, "")) } - router.POST("/api/secondfactor/duo", autheliaMiddleware( + r.POST("/api/secondfactor/duo", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.SecondFactorDuoPost(duoAPI)))) } // If trace is set, enable pprofhandler and expvarhandler. if configuration.LogLevel == "trace" { - router.GET("/debug/pprof/{name?}", pprofhandler.PprofHandler) - router.GET("/debug/vars", expvarhandler.ExpvarHandler) + r.GET("/debug/pprof/{name?}", pprofhandler.PprofHandler) + r.GET("/debug/vars", expvarhandler.ExpvarHandler) } - router.NotFound = ServeIndex(embeddedAssets) + r.NotFound = ServeIndex(embeddedAssets, configuration.Server.Path) + + handler := middlewares.LogRequestMiddleware(r.Handler) + if configuration.Server.Path != "" { + handler = middlewares.StripPathMiddleware(handler) + } server := &fasthttp.Server{ ErrorHandler: autheliaErrorHandler, - Handler: middlewares.LogRequestMiddleware(router.Handler), + Handler: handler, NoDefaultServerHeader: true, ReadBufferSize: configuration.Server.ReadBufferSize, WriteBufferSize: configuration.Server.WriteBufferSize, diff --git a/internal/utils/strings.go b/internal/utils/strings.go index 9ee77b15c..bfe957d48 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -3,8 +3,20 @@ package utils import ( "math/rand" "time" + "unicode" ) +// IsStringAlphaNumeric returns false if any rune in the string is not alpha-numeric. +func IsStringAlphaNumeric(input string) bool { + for _, r := range input { + if !unicode.IsLetter(r) && !unicode.IsNumber(r) { + return false + } + } + + return true +} + // IsStringInSlice checks if a single string is in an array of strings. func IsStringInSlice(a string, list []string) (inSlice bool) { for _, b := range list { diff --git a/web/.env.development b/web/.env.development index cacb4b96f..faabb044c 100644 --- a/web/.env.development +++ b/web/.env.development @@ -1,2 +1,2 @@ - -HOST=authelia-frontend \ No newline at end of file +HOST=authelia-frontend +PUBLIC_URL="" \ No newline at end of file diff --git a/web/.env.production b/web/.env.production new file mode 100644 index 000000000..206b820f5 --- /dev/null +++ b/web/.env.production @@ -0,0 +1 @@ +PUBLIC_URL={{.Base}} \ No newline at end of file diff --git a/web/public/index.html b/web/public/index.html index df8a64a79..e24d9350f 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -1,22 +1,19 @@ -
- - - - - - - - - -