feat: customizable static assets (#2597)

* feat: customizable static assets

This change provides the means to override specific assets from the embedded Go FS with files situated on disk.

We only allow overriding the following files currently:
* favicon.ico
* logo.png

* refactor(server): make logo string a const

* refactor(suites): override favicon and use ntp3 in traefik2 suite

* test(suites): test logo override in traefik2 suite

* test(suites): test asset override fallback in traefik suite

Closes #1630.
This commit is contained in:
Amir Zarrinkafsh 2021-11-15 19:37:58 +11:00 committed by GitHub
parent 417d421b9a
commit 0be883befb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 120 additions and 17 deletions

View File

@ -7,7 +7,7 @@
## Certificates directory specifies where Authelia will load trusted certificates (public portion) from in addition to ## Certificates directory specifies where Authelia will load trusted certificates (public portion) from in addition to
## the system certificates store. ## the system certificates store.
## They should be in base64 format, and have one of the following extensions: *.cer, *.crt, *.pem. ## They should be in base64 format, and have one of the following extensions: *.cer, *.crt, *.pem.
# certificates_directory: /config/certificates # certificates_directory: /config/certificates/
## The theme to display: light, dark, grey, auto. ## The theme to display: light, dark, grey, auto.
theme: light theme: light
@ -40,6 +40,10 @@ server:
## Must be alphanumeric chars and should not contain any slashes. ## Must be alphanumeric chars and should not contain any slashes.
path: "" path: ""
## Set the path on disk to Authelia assets.
## Useful to allow overriding of specific static assets.
# asset_path: /config/assets/
## Buffers usually should be configured to be the same value. ## Buffers usually should be configured to be the same value.
## Explanation at https://www.authelia.com/docs/configuration/server.html ## Explanation at https://www.authelia.com/docs/configuration/server.html
## Read buffer size adjusts the server's max incoming request size in bytes. ## Read buffer size adjusts the server's max incoming request size in bytes.

View File

@ -87,6 +87,33 @@ server:
path: authelia path: authelia
``` ```
### asset_path
<div markdown="1">
type: string
{: .label .label-config .label-purple }
default: ""
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
Authelia by default serves all static assets from an embedded filesystem in the Go binary.
Modifying this setting will allow you to override and serve specific assets for Authelia from a specified path.
All files that can be overridden are documented below and must be placed in the `asset_path` with a flat file structure.
Example:
```console
/config/assets/
├── favicon.ico
└── logo.png
```
|Asset |File name|
|:-----:|:---------------:|
|Favicon|favicon.ico |
|Logo |logo.png |
### read_buffer_size ### read_buffer_size
<div markdown="1"> <div markdown="1">
type: integer type: integer
@ -189,3 +216,8 @@ The path to the public certificate for TLS connections. Must be in DER base64/PE
The read and write buffer sizes generally should be the same. This is because when Authelia verifies 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 as the request. However if the user is authorized to visit a URL, it also sends back nearly the same size response as the request. However
you're able to tune these individually depending on your needs. you're able to tune these individually depending on your needs.
### Asset Overrides
If replacing the Logo for your Authelia portal it is recommended to upload a transparent PNG of your desired logo.
Authelia will automatically resize the logo to an appropriate size to present in the frontend.

View File

@ -7,7 +7,7 @@
## Certificates directory specifies where Authelia will load trusted certificates (public portion) from in addition to ## Certificates directory specifies where Authelia will load trusted certificates (public portion) from in addition to
## the system certificates store. ## the system certificates store.
## They should be in base64 format, and have one of the following extensions: *.cer, *.crt, *.pem. ## They should be in base64 format, and have one of the following extensions: *.cer, *.crt, *.pem.
# certificates_directory: /config/certificates # certificates_directory: /config/certificates/
## The theme to display: light, dark, grey, auto. ## The theme to display: light, dark, grey, auto.
theme: light theme: light
@ -40,6 +40,10 @@ server:
## Must be alphanumeric chars and should not contain any slashes. ## Must be alphanumeric chars and should not contain any slashes.
path: "" path: ""
## Set the path on disk to Authelia assets.
## Useful to allow overriding of specific static assets.
# asset_path: /config/assets/
## Buffers usually should be configured to be the same value. ## Buffers usually should be configured to be the same value.
## Explanation at https://www.authelia.com/docs/configuration/server.html ## Explanation at https://www.authelia.com/docs/configuration/server.html
## Read buffer size adjusts the server's max incoming request size in bytes. ## Read buffer size adjusts the server's max incoming request size in bytes.

View File

@ -5,6 +5,7 @@ type ServerConfiguration struct {
Host string `koanf:"host"` Host string `koanf:"host"`
Port int `koanf:"port"` Port int `koanf:"port"`
Path string `koanf:"path"` Path string `koanf:"path"`
AssetPath string `koanf:"asset_path"`
ReadBufferSize int `koanf:"read_buffer_size"` ReadBufferSize int `koanf:"read_buffer_size"`
WriteBufferSize int `koanf:"write_buffer_size"` WriteBufferSize int `koanf:"write_buffer_size"`
EnablePprof bool `koanf:"enable_endpoint_pprof"` EnablePprof bool `koanf:"enable_endpoint_pprof"`

View File

@ -147,6 +147,7 @@ var ValidKeys = []string{
"server.read_buffer_size", "server.read_buffer_size",
"server.write_buffer_size", "server.write_buffer_size",
"server.path", "server.path",
"server.asset_path",
"server.enable_pprof", "server.enable_pprof",
"server.enable_expvars", "server.enable_expvars",
"server.disable_healthcheck", "server.disable_healthcheck",

View File

@ -0,0 +1,29 @@
package middlewares
import (
"os"
"strings"
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/utils"
)
// AssetOverrideMiddleware allows overriding and serving of specific embedded assets from disk.
func AssetOverrideMiddleware(assetPath string, next fasthttp.RequestHandler) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
uri := string(ctx.RequestURI())
file := uri[strings.LastIndex(uri, "/")+1:]
if assetPath != "" && utils.IsStringInSlice(file, validOverrideAssets) {
_, err := os.Stat(assetPath + file)
if err != nil {
next(ctx)
} else {
fasthttp.FSHandler(assetPath, strings.Count(uri, "/")-1)(ctx)
}
} else {
next(ctx)
}
}
}

View File

@ -29,3 +29,4 @@ const (
) )
var protoHostSeparator = []byte("://") var protoHostSeparator = []byte("://")
var validOverrideAssets = []string{"favicon.ico", "logo.png"}

View File

@ -1,9 +1,14 @@
package server package server
const embeddedAssets = "public_html/" const (
const swaggerAssets = embeddedAssets + "api/" embeddedAssets = "public_html/"
const apiFile = "openapi.yml" swaggerAssets = embeddedAssets + "api/"
const indexFile = "index.html" apiFile = "openapi.yml"
indexFile = "index.html"
logoFile = "logo.png"
)
var rootFiles = []string{"favicon.ico", "manifest.json", "robots.txt"}
const dev = "dev" const dev = "dev"

View File

@ -32,13 +32,12 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
embeddedPath, _ := fs.Sub(assets, "public_html") embeddedPath, _ := fs.Sub(assets, "public_html")
embeddedFS := fasthttpadaptor.NewFastHTTPHandler(http.FileServer(http.FS(embeddedPath))) embeddedFS := fasthttpadaptor.NewFastHTTPHandler(http.FileServer(http.FS(embeddedPath)))
rootFiles := []string{"favicon.ico", "manifest.json", "robots.txt"}
https := configuration.Server.TLS.Key != "" && configuration.Server.TLS.Certificate != "" https := configuration.Server.TLS.Key != "" && configuration.Server.TLS.Certificate != ""
serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https) serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, configuration.Server.AssetPath, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
serveSwaggerHandler := ServeTemplatedFile(swaggerAssets, indexFile, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https) serveSwaggerHandler := ServeTemplatedFile(swaggerAssets, indexFile, configuration.Server.AssetPath, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
serveSwaggerAPIHandler := ServeTemplatedFile(swaggerAssets, apiFile, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https) serveSwaggerAPIHandler := ServeTemplatedFile(swaggerAssets, apiFile, configuration.Server.AssetPath, rememberMe, resetPassword, configuration.Session.Name, configuration.Theme, https)
r := router.New() r := router.New()
r.GET("/", serveIndexHandler) r.GET("/", serveIndexHandler)
@ -48,10 +47,10 @@ func registerRoutes(configuration schema.Configuration, providers middlewares.Pr
r.GET("/api/"+apiFile, serveSwaggerAPIHandler) r.GET("/api/"+apiFile, serveSwaggerAPIHandler)
for _, f := range rootFiles { for _, f := range rootFiles {
r.GET("/"+f, embeddedFS) r.GET("/"+f, middlewares.AssetOverrideMiddleware(configuration.Server.AssetPath, embeddedFS))
} }
r.GET("/static/{filepath:*}", embeddedFS) r.GET("/static/{filepath:*}", middlewares.AssetOverrideMiddleware(configuration.Server.AssetPath, embeddedFS))
r.ANY("/api/{filepath:*}", embeddedFS) r.ANY("/api/{filepath:*}", embeddedFS)
r.GET("/api/health", autheliaMiddleware(handlers.HealthGet)) r.GET("/api/health", autheliaMiddleware(handlers.HealthGet))

View File

@ -16,7 +16,7 @@ import (
// ServeTemplatedFile serves a templated version of a specified file, // ServeTemplatedFile serves a templated version of a specified file,
// this is utilised to pass information between the backend and frontend // this is utilised to pass information between the backend and frontend
// and generate a nonce to support a restrictive CSP while using material-ui. // and generate a nonce to support a restrictive CSP while using material-ui.
func ServeTemplatedFile(publicDir, file, rememberMe, resetPassword, session, theme string, https bool) fasthttp.RequestHandler { func ServeTemplatedFile(publicDir, file, assetPath, rememberMe, resetPassword, session, theme string, https bool) fasthttp.RequestHandler {
logger := logging.Logger() logger := logging.Logger()
f, err := assets.Open(publicDir + file) f, err := assets.Open(publicDir + file)
@ -40,6 +40,14 @@ func ServeTemplatedFile(publicDir, file, rememberMe, resetPassword, session, the
base = baseURL.(string) base = baseURL.(string)
} }
logoOverride := "false"
if assetPath != "" {
if _, err := os.Stat(assetPath + logoFile); err == nil {
logoOverride = "true"
}
}
var scheme = "https" var scheme = "https"
if !https { if !https {
@ -71,7 +79,7 @@ func ServeTemplatedFile(publicDir, file, rememberMe, resetPassword, session, the
ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("default-src 'self' ; object-src 'none'; style-src 'self' 'nonce-%s'", nonce)) ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("default-src 'self' ; object-src 'none'; style-src 'self' 'nonce-%s'", nonce))
} }
err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, BaseURL, CSPNonce, RememberMe, ResetPassword, Session, Theme string }{Base: base, BaseURL: baseURL, CSPNonce: nonce, RememberMe: rememberMe, ResetPassword: resetPassword, Session: session, Theme: theme}) err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, BaseURL, CSPNonce, LogoOverride, RememberMe, ResetPassword, Session, Theme string }{Base: base, BaseURL: baseURL, CSPNonce: nonce, LogoOverride: logoOverride, RememberMe: rememberMe, ResetPassword: resetPassword, Session: session, Theme: theme})
if err != nil { if err != nil {
ctx.Error("an error occurred", 503) ctx.Error("an error occurred", 503)
logger.Errorf("Unable to execute template: %v", err) logger.Errorf("Unable to execute template: %v", err)

View File

@ -7,6 +7,7 @@ jwt_secret: unsecure_secret
server: server:
port: 9091 port: 9091
asset_path: /config/assets/
tls: tls:
certificate: /config/ssl/cert.pem certificate: /config/ssl/cert.pem
key: /config/ssl/key.pem key: /config/ssl/key.pem

View File

@ -7,6 +7,7 @@ jwt_secret: unsecure_secret
server: server:
port: 9091 port: 9091
asset_path: /config/assets/
tls: tls:
certificate: /config/ssl/cert.pem certificate: /config/ssl/cert.pem
key: /config/ssl/key.pem key: /config/ssl/key.pem
@ -46,6 +47,9 @@ access_control:
- domain: "singlefactor.example.com" - domain: "singlefactor.example.com"
policy: one_factor policy: one_factor
ntp:
version: 3
notifier: notifier:
smtp: smtp:
host: smtp host: smtp

View File

@ -5,5 +5,7 @@ services:
volumes: volumes:
- './Traefik2/configuration.yml:/config/configuration.yml:ro' - './Traefik2/configuration.yml:/config/configuration.yml:ro'
- './Traefik2/users.yml:/config/users.yml' - './Traefik2/users.yml:/config/users.yml'
- './Traefik2/favicon.ico:/config/assets/favicon.ico'
- './Traefik2/logo.png:/config/assets/logo.png'
- './common/ssl:/config/ssl:ro' - './common/ssl:/config/ssl:ro'
... ...

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,4 +1,5 @@
VITE_HMR_PORT=8080 VITE_HMR_PORT=8080
VITE_LOGO_OVERRIDE=false
VITE_PUBLIC_URL="" VITE_PUBLIC_URL=""
VITE_REMEMBER_ME=true VITE_REMEMBER_ME=true
VITE_RESET_PASSWORD=true VITE_RESET_PASSWORD=true

View File

@ -1,3 +1,4 @@
VITE_LOGO_OVERRIDE={{.LogoOverride}}
VITE_PUBLIC_URL={{.Base}} VITE_PUBLIC_URL={{.Base}}
VITE_REMEMBER_ME={{.RememberMe}} VITE_REMEMBER_ME={{.RememberMe}}
VITE_RESET_PASSWORD={{.ResetPassword}} VITE_RESET_PASSWORD={{.ResetPassword}}

View File

@ -13,7 +13,7 @@
<title>Login - Authelia</title> <title>Login - Authelia</title>
</head> </head>
<body data-basepath="%VITE_PUBLIC_URL%" data-rememberme="%VITE_REMEMBER_ME%" data-resetpassword="%VITE_RESET_PASSWORD%" data-theme="%VITE_THEME%"> <body data-basepath="%VITE_PUBLIC_URL%" data-logooverride="%VITE_LOGO_OVERRIDE%" data-rememberme="%VITE_REMEMBER_ME%" data-resetpassword="%VITE_RESET_PASSWORD%" data-theme="%VITE_THEME%">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>

View File

@ -56,7 +56,7 @@
"sourcemap": true "sourcemap": true
} }
], ],
"^.+\\.(css|svg)$": "jest-transform-stub" "^.+\\.(css|png|svg)$": "jest-transform-stub"
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$" "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$"

View File

@ -4,6 +4,7 @@ import { Grid, makeStyles, Container, Typography, Link } from "@material-ui/core
import { grey } from "@material-ui/core/colors"; import { grey } from "@material-ui/core/colors";
import { ReactComponent as UserSvg } from "@assets/images/user.svg"; import { ReactComponent as UserSvg } from "@assets/images/user.svg";
import { getLogoOverride } from "@utils/Configuration";
export interface Props { export interface Props {
id?: string; id?: string;
@ -14,12 +15,17 @@ export interface Props {
const LoginLayout = function (props: Props) { const LoginLayout = function (props: Props) {
const style = useStyles(); const style = useStyles();
const logo = getLogoOverride() ? (
<img src="./static/media/logo.png" alt="Logo" className={style.icon} />
) : (
<UserSvg className={style.icon} />
);
return ( return (
<Grid id={props.id} className={style.root} container spacing={0} alignItems="center" justifyContent="center"> <Grid id={props.id} className={style.root} container spacing={0} alignItems="center" justifyContent="center">
<Container maxWidth="xs" className={style.rootContainer}> <Container maxWidth="xs" className={style.rootContainer}>
<Grid container> <Grid container>
<Grid item xs={12}> <Grid item xs={12}>
<UserSvg className={style.icon}></UserSvg> {logo}
</Grid> </Grid>
{props.title ? ( {props.title ? (
<Grid item xs={12}> <Grid item xs={12}>

View File

@ -7,6 +7,10 @@ export function getEmbeddedVariable(variableName: string) {
return value; return value;
} }
export function getLogoOverride() {
return getEmbeddedVariable("logooverride") === "true";
}
export function getRememberMe() { export function getRememberMe() {
return getEmbeddedVariable("rememberme") === "true"; return getEmbeddedVariable("rememberme") === "true";
} }