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 @@ - - - - - - - - - - - Login - Authelia - - - -
- - - + + + \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx index fbba00f91..cbe040f56 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,7 +19,8 @@ import NotificationBar from './components/NotificationBar'; import SignOut from './views/LoginPortal/SignOut/SignOut'; import { useConfiguration } from './hooks/Configuration'; import '@fortawesome/fontawesome-svg-core/styles.css' -import {config as faConfig} from '@fortawesome/fontawesome-svg-core'; +import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; +import { useBasePath } from './hooks/BasePath'; faConfig.autoAddCss = false; @@ -37,7 +38,7 @@ const App: React.FC = () => { return ( - + setNotification(null)} /> @@ -61,7 +62,7 @@ const App: React.FC = () => { resetPassword={configuration?.reset_password === true} /> - + diff --git a/web/src/constants.ts b/web/src/constants.ts index 4b4fb98ff..bcb675931 100644 --- a/web/src/constants.ts +++ b/web/src/constants.ts @@ -2,4 +2,4 @@ export const GoogleAuthenticator = { googlePlay: "https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en_us", appleStore: "https://apps.apple.com/us/app/google-authenticator/id388497605", -}; +}; \ No newline at end of file diff --git a/web/src/hooks/BasePath.ts b/web/src/hooks/BasePath.ts new file mode 100644 index 000000000..3c2e6e425 --- /dev/null +++ b/web/src/hooks/BasePath.ts @@ -0,0 +1,8 @@ +export function useBasePath() { + const basePath = document.body.getAttribute("data-basepath"); + if (basePath === null) { + throw new Error("No base path detected"); + } + + return basePath; +} \ No newline at end of file diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index 3df7ddf33..77ed345b4 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -1,32 +1,34 @@ import { AxiosResponse } from "axios"; +import { useBasePath } from "../hooks/BasePath"; -export const FirstFactorPath = "/api/firstfactor"; -export const InitiateTOTPRegistrationPath = "/api/secondfactor/totp/identity/start"; -export const CompleteTOTPRegistrationPath = "/api/secondfactor/totp/identity/finish"; +const basePath = useBasePath(); -export const InitiateU2FRegistrationPath = "/api/secondfactor/u2f/identity/start"; -export const CompleteU2FRegistrationStep1Path = "/api/secondfactor/u2f/identity/finish"; -export const CompleteU2FRegistrationStep2Path = "/api/secondfactor/u2f/register"; +export const FirstFactorPath = basePath + "/api/firstfactor"; +export const InitiateTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/start"; +export const CompleteTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/finish"; -export const InitiateU2FSignInPath = "/api/secondfactor/u2f/sign_request"; -export const CompleteU2FSignInPath = "/api/secondfactor/u2f/sign"; +export const InitiateU2FRegistrationPath = basePath + "/api/secondfactor/u2f/identity/start"; +export const CompleteU2FRegistrationStep1Path = basePath + "/api/secondfactor/u2f/identity/finish"; +export const CompleteU2FRegistrationStep2Path = basePath + "/api/secondfactor/u2f/register"; -export const CompletePushNotificationSignInPath = "/api/secondfactor/duo" -export const CompleteTOTPSignInPath = "/api/secondfactor/totp" +export const InitiateU2FSignInPath = basePath + "/api/secondfactor/u2f/sign_request"; +export const CompleteU2FSignInPath = basePath + "/api/secondfactor/u2f/sign"; -export const InitiateResetPasswordPath = "/api/reset-password/identity/start"; -export const CompleteResetPasswordPath = "/api/reset-password/identity/finish"; +export const CompletePushNotificationSignInPath = basePath + "/api/secondfactor/duo" +export const CompleteTOTPSignInPath = basePath + "/api/secondfactor/totp" + +export const InitiateResetPasswordPath = basePath + "/api/reset-password/identity/start"; +export const CompleteResetPasswordPath = basePath + "/api/reset-password/identity/finish"; // Do the password reset during completion. -export const ResetPasswordPath = "/api/reset-password" +export const ResetPasswordPath = basePath + "/api/reset-password" -export const LogoutPath = "/api/logout"; -export const StatePath = "/api/state"; -export const UserInfoPath = "/api/user/info"; -export const UserInfo2FAMethodPath = "/api/user/info/2fa_method"; -export const Available2FAMethodsPath = "/api/secondfactor/available"; +export const LogoutPath = basePath + "/api/logout"; +export const StatePath = basePath + "/api/state"; +export const UserInfoPath = basePath + "/api/user/info"; +export const UserInfo2FAMethodPath = basePath + "/api/user/info/2fa_method"; -export const ConfigurationPath = "/api/configuration"; -export const ExtendedConfigurationPath = "/api/configuration/extended"; +export const ConfigurationPath = basePath + "/api/configuration"; +export const ExtendedConfigurationPath = basePath + "/api/configuration/extended"; export interface ErrorResponse { status: "KO"; diff --git a/web/src/services/Client.ts b/web/src/services/Client.ts index 32831a9a2..b48e3c6fc 100644 --- a/web/src/services/Client.ts +++ b/web/src/services/Client.ts @@ -18,14 +18,14 @@ export async function Post(path: string, body?: any) { return res; } -export async function Get(path: string) { +export async function Get(path: string): Promise { const res = await axios.get>(path); if (res.status !== 200 || hasServiceError(res)) { throw new Error(`Failed GET from ${path}. Code: ${res.status}.`); } - const d = toData(res); + const d = toData(res); if (!d) { throw new Error("unexpected type of response"); } diff --git a/web/src/services/State.ts b/web/src/services/State.ts index 370a1c3e1..5b1830983 100644 --- a/web/src/services/State.ts +++ b/web/src/services/State.ts @@ -12,6 +12,6 @@ export interface AutheliaState { authentication_level: AuthenticationLevel } -export function getState() { +export async function getState(): Promise { return Get(StatePath); } \ No newline at end of file diff --git a/web/src/setupTests.js b/web/src/setupTests.js index 3482c8637..71a979d56 100644 --- a/web/src/setupTests.js +++ b/web/src/setupTests.js @@ -1,3 +1,4 @@ import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; +document.body.setAttribute("data-basepath", ""); configure({ adapter: new Adapter() });