[FEATURE] Allow Authelia to listen on a specified path (#1027)

* [FEATURE] Allow Authelia to listen on a specified path

* Fix linting and add a couple typescript types

* Template index.html to support base_url

* Update docs and configuration template

* Access base path from body attribute.

* Update CSP

* Fix unit test
Also remove check for body as this will never get triggered, react itself is loaded inside the body so this has to always be successful.

* Template index.html with ${PUBLIC_URL}

* Define PUBLIC_URL in .env(s)

* Add docs clarification

Co-authored-by: Amir Zarrinkafsh <nightah@me.com>
Co-authored-by: Clement Michaud <clement.michaud34@gmail.com>
pull/1036/head
James Elliott 2020-05-21 12:20:55 +10:00 committed by GitHub
parent 469daedd36
commit fcd0b5e46a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 196 additions and 90 deletions

View File

@ -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

View File

@ -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
```

View File

@ -2,6 +2,7 @@ package schema
// ServerConfiguration represents the configuration of the http server.
type ServerConfiguration struct {
Path string `mapstructure:"path"`
ReadBufferSize int `mapstructure:"read_buffer_size"`
WriteBufferSize int `mapstructure:"write_buffer_size"`
}

View File

@ -14,6 +14,7 @@ var validKeys = []string{
// Server Keys.
"server.read_buffer_size",
"server.write_buffer_size",
"server.path",
// TOTP Keys.
"totp.issuer",

View File

@ -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 {

View File

@ -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")
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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 {

View File

@ -1,2 +1,2 @@
HOST=authelia-frontend
PUBLIC_URL=""

View File

@ -0,0 +1 @@
PUBLIC_URL={{.Base}}

View File

@ -1,21 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta property="csp-nonce" content="{{.CSPNonce}}" />
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Authelia login portal for your apps"
/>
<meta name="description" content="Authelia login portal for your apps" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
@ -27,7 +24,8 @@
-->
<title>Login - Authelia</title>
</head>
<body>
<body data-basepath="%PUBLIC_URL%">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
@ -41,4 +39,5 @@
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -20,6 +20,7 @@ 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 { useBasePath } from './hooks/BasePath';
faConfig.autoAddCss = false;
@ -37,7 +38,7 @@ const App: React.FC = () => {
return (
<NotificationsContext.Provider value={{ notification, setNotification }} >
<Router>
<Router basename={useBasePath()}>
<NotificationBar onClose={() => setNotification(null)} />
<Switch>
<Route path={ResetPasswordStep1Route} exact>
@ -61,7 +62,7 @@ const App: React.FC = () => {
resetPassword={configuration?.reset_password === true} />
</Route>
<Route path="/">
<Redirect to={FirstFactorRoute}></Redirect>
<Redirect to={FirstFactorRoute} />
</Route>
</Switch>
</Router>

View File

@ -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;
}

View File

@ -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";

View File

@ -18,14 +18,14 @@ export async function Post<T>(path: string, body?: any) {
return res;
}
export async function Get<T = undefined>(path: string) {
export async function Get<T = undefined>(path: string): Promise<T> {
const res = await axios.get<ServiceResponse<T>>(path);
if (res.status !== 200 || hasServiceError(res)) {
throw new Error(`Failed GET from ${path}. Code: ${res.status}.`);
}
const d = toData(res);
const d = toData<T>(res);
if (!d) {
throw new Error("unexpected type of response");
}

View File

@ -12,6 +12,6 @@ export interface AutheliaState {
authentication_level: AuthenticationLevel
}
export function getState() {
export async function getState(): Promise<AutheliaState> {
return Get<AutheliaState>(StatePath);
}

View File

@ -1,3 +1,4 @@
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
document.body.setAttribute("data-basepath", "");
configure({ adapter: new Adapter() });