perf(server): cached openapi document (#4674)

This should lead to a small performance gain by caching the openapi.yml with etags as well as eliminating the use of nonce crypto generation when not required.
pull/4682/head
James Elliott 2023-01-03 14:49:02 +11:00 committed by GitHub
parent acaadd81cb
commit 1c3219e93f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 631 additions and 422 deletions

View File

@ -9,6 +9,7 @@ yaml-files:
- '.yamllint'
ignore: |
api/openapi.yml
docs/pnpm-lock.yaml
internal/configuration/test_resources/config_bad_quoting.yml
web/pnpm-lock.yaml

View File

@ -1,4 +1,3 @@
# yamllint disable rule:line-length
---
openapi: 3.0.3
info:
@ -22,18 +21,24 @@ tags:
description: Configuration, health and state endpoints
- name: Authentication
description: Authentication and verification endpoints
{{- if .PasswordReset }}
- name: Password Reset
description: Password reset endpoints
- name: User Information
description: User configuration endpoints
{{- end }}
{{- if (or .TOTP .Webauthn .Duo) }}
- name: Second Factor
description: TOTP, Webauthn and Duo endpoints
externalDocs:
url: https://www.authelia.com/configuration/second-factor/introduction/
{{- end }}
{{- if .OpenIDConnect }}
- name: OpenID Connect 1.0
description: OpenID Connect 1.0 and OAuth 2.0 Endpoints
externalDocs:
url: https://www.authelia.com/integration/openid-connect/introduction/
{{- end }}
paths:
/api/configuration:
get:
@ -97,280 +102,8 @@ paths:
schema:
$ref: '#/components/schemas/handlers.StateResponse'
/api/verify:
get:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
head:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
options:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
post:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
put:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
patch:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
delete:
tags:
- Authentication
summary: Verification
description: >
The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified
domain.
parameters:
- $ref: '#/components/parameters/originalURLParam'
- $ref: '#/components/parameters/forwardedMethodParam'
- $ref: '#/components/parameters/authParam'
responses:
"200":
description: Successful Operation
headers:
remote-user:
description: Username
schema:
type: string
example: john
remote-name:
description: Name
schema:
type: string
example: John Doe
remote-email:
description: Email
schema:
type: string
example: john.doe@authelia.com
remote-groups:
description: Comma separated list of Groups
schema:
type: string
example: admin,devs
"401":
description: Unauthorized
security:
- authelia_auth: []
trace:
{{- range $method := list "get" "head" "options" "post" "put" "patch" "delete" "trace" }}
{{ $method }}:
tags:
- Authentication
summary: Verification
@ -409,6 +142,7 @@ paths:
description: Unauthorized
security:
- authelia_auth: []
{{- end }}
/api/firstfactor:
post:
tags:
@ -477,6 +211,7 @@ paths:
$ref: '#/components/schemas/handlers.logoutResponseBody'
security:
- authelia_auth: []
{{- if .PasswordReset }}
/api/reset-password/identity/start:
post:
tags:
@ -494,7 +229,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.resetPasswordStep1RequestBody'
$ref: '#/components/schemas/handlers.PasswordResetStep1RequestBody'
responses:
"200":
description: Successful Operation
@ -546,7 +281,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.resetPasswordStep2RequestBody'
$ref: '#/components/schemas/handlers.PasswordResetStep2RequestBody'
responses:
"200":
description: Successful Operation
@ -556,6 +291,7 @@ paths:
$ref: '#/components/schemas/middlewares.OkResponse'
security:
- authelia_auth: []
{{- end }}
/api/user/info:
get:
tags:
@ -593,25 +329,6 @@ paths:
description: Forbidden
security:
- authelia_auth: []
/api/user/info/totp:
get:
tags:
- User Information
summary: User TOTP Configuration
description: >
The user TOTP info endpoint provides information necessary to display the TOTP component to validate their
TOTP input such as the period/frequency and number of digits.
responses:
"200":
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.UserInfoTOTP'
"403":
description: Forbidden
security:
- authelia_auth: []
/api/user/info/2fa_method:
post:
tags:
@ -634,6 +351,26 @@ paths:
description: Forbidden
security:
- authelia_auth: []
{{- if .TOTP }}
/api/user/info/totp:
get:
tags:
- User Information
summary: User TOTP Configuration
description: >
The user TOTP info endpoint provides information necessary to display the TOTP component to validate their
TOTP input such as the period/frequency and number of digits.
responses:
"200":
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/handlers.UserInfoTOTP'
"403":
description: Forbidden
security:
- authelia_auth: []
/api/secondfactor/totp/identity/start:
post:
tags:
@ -706,6 +443,8 @@ paths:
$ref: '#/components/schemas/middlewares.ErrorResponse'
security:
- authelia_auth: []
{{- end }}
{{- if .Webauthn }}
/api/secondfactor/webauthn/assertion:
get:
tags:
@ -812,6 +551,8 @@ paths:
$ref: '#/components/schemas/middlewares.OkResponse'
security:
- authelia_auth: []
{{- end }}
{{- if .Duo }}
/api/secondfactor/duo:
post:
tags:
@ -875,6 +616,8 @@ paths:
description: Unauthorized
security:
- authelia_auth: []
{{- end }}
{{- if .OpenIDConnect }}
/.well-known/openid-configuration:
get:
tags:
@ -1389,6 +1132,7 @@ paths:
description: Forbidden
security:
- authelia_auth: []
{{- end }}
components:
parameters:
originalURLParam:
@ -1609,7 +1353,8 @@ components:
redirect:
type: string
example: https://home.example.com
handlers.resetPasswordStep1RequestBody:
{{- if .PasswordReset }}
handlers.PasswordResetStep1RequestBody:
required:
- username
type: object
@ -1617,7 +1362,7 @@ components:
username:
type: string
example: john
handlers.resetPasswordStep2RequestBody:
handlers.PasswordResetStep2RequestBody:
required:
- password
type: object
@ -1625,6 +1370,8 @@ components:
password:
type: string
example: password
{{- end }}
{{- if .Duo }}
handlers.bodySignDuoRequest:
type: object
properties:
@ -1641,23 +1388,7 @@ components:
format: uuid
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$'
example: "3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c"
handlers.bodySignTOTPRequest:
type: object
properties:
token:
type: string
example: "123456"
targetURL:
type: string
example: https://secure.example.com
workflow:
type: string
example: openid_connect
workflowID:
type: string
format: uuid
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$'
example: "3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c"
{{- end }}
handlers.StateResponse:
type: object
properties:
@ -1676,7 +1407,24 @@ components:
default_redirection_url:
type: string
example: https://home.example.com
handlers.TOTPKeyResponse:
middlewares.ErrorResponse:
type: object
properties:
status:
type: string
example: KO
message:
type: string
example: Authentication failed, please retry later.
middlewares.IdentityVerificationFinishBody:
required:
- token
type: object
properties:
token:
type: string
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDc5MjU1OTYsImlzcyI6IkF1dGhlbGlhIiwiYWN0aW9uIjoiUmVzZXRQYXNzd29yZCIsInVzZXJuYW1lIjoiQW1pciJ9.636yqRrUCGCe4jsMCsonleX5CYWHncYqZum-YYb6VaY
middlewares.OkResponse:
type: object
properties:
status:
@ -1684,13 +1432,6 @@ components:
example: OK
data:
type: object
properties:
base32_secret:
type: string
example: 5ZH7Y5CTFWOXN7EOLGBMMXADRNQFHVUDZSYKCN5HMFAIRSLAWY3Q
otpauth_url:
type: string
example: otpauth://totp/auth.example.com:john?algorithm=SHA1&digits=6&issuer=auth.example.com&period=30&secret=5ZH7Y5CTFWOXN7EOLGBMMXADRNQFHVUDZSYKCN5HMFAIRSLAWY3Q
handlers.UserInfo:
type: object
properties:
@ -1719,6 +1460,19 @@ components:
has_duo:
type: boolean
example: true
handlers.UserInfo.MethodBody:
required:
- method
type: object
properties:
method:
type: string
enum:
- "totp"
- "webauthn"
- "mobile_push"
example: totp
{{- if .TOTP }}
handlers.UserInfoTOTP:
type: object
properties:
@ -1738,36 +1492,24 @@ components:
description: The number of digits defined in the users TOTP configuration
type: integer
example: 6
handlers.UserInfo.MethodBody:
required:
- method
type: object
properties:
method:
type: string
enum:
- "totp"
- "webauthn"
- "mobile_push"
example: totp
middlewares.ErrorResponse:
type: object
properties:
status:
type: string
example: KO
message:
type: string
example: Authentication failed, please retry later.
middlewares.IdentityVerificationFinishBody:
required:
- token
handlers.bodySignTOTPRequest:
type: object
properties:
token:
type: string
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDc5MjU1OTYsImlzcyI6IkF1dGhlbGlhIiwiYWN0aW9uIjoiUmVzZXRQYXNzd29yZCIsInVzZXJuYW1lIjoiQW1pciJ9.636yqRrUCGCe4jsMCsonleX5CYWHncYqZum-YYb6VaY
middlewares.OkResponse:
example: "123456"
targetURL:
type: string
example: https://secure.example.com
workflow:
type: string
example: openid_connect
workflowID:
type: string
format: uuid
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$'
example: "3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c"
handlers.TOTPKeyResponse:
type: object
properties:
status:
@ -1775,6 +1517,15 @@ components:
example: OK
data:
type: object
properties:
base32_secret:
type: string
example: 5ZH7Y5CTFWOXN7EOLGBMMXADRNQFHVUDZSYKCN5HMFAIRSLAWY3Q
otpauth_url:
type: string
example: otpauth://totp/auth.example.com:john?algorithm=SHA1&digits=6&issuer=auth.example.com&period=30&secret=5ZH7Y5CTFWOXN7EOLGBMMXADRNQFHVUDZSYKCN5HMFAIRSLAWY3Q
{{- end }}
{{- if .Webauthn }}
webauthn.PublicKeyCredential:
type: object
properties:
@ -2073,6 +1824,8 @@ components:
written:
type: boolean
example: false
{{- end }}
{{- if .OpenIDConnect }}
openid.request.consent:
type: object
properties:
@ -3669,12 +3422,15 @@ components:
type: array
items:
$ref: '#/components/schemas/jose.spec.JWK'
{{- end }}
securitySchemes:
authelia_auth:
type: apiKey
name: "{{ .Session }}"
in: cookie
{{- if .OpenIDConnect }}
openid:
type: openIdConnect
openIdConnectUrl: "{{ .BaseURL }}.well-known/openid-configuration"
{{- end }}
...

View File

@ -56,6 +56,26 @@ The following functions which mimic the behaviour of helm exist in most templati
- b64dec
- b32enc
- b32dec
- list
- dict
- get
- set
- isAbs
- base
- dir
- ext
- clean
- osBase
- osClean
- osDir
- osExt
- osIsAbs
- deepEqual
- typeOf
- typeIs
- typeIsLike
- kindOf
- kindIs
See the [Helm Documentation](https://helm.sh/docs/chart_template_guide/function_list/) for more information. Please
note that only the functions listed above are supported and the functions don't necessarily behave exactly the same.

View File

@ -396,3 +396,8 @@ func (ctx *AutheliaCtx) SetContentTypeTextHTML() {
func (ctx *AutheliaCtx) SetContentTypeApplicationJSON() {
ctx.SetContentTypeBytes(contentTypeApplicationJSON)
}
// SetContentTypeApplicationYAML efficiently sets the Content-Type header to 'application/yaml; charset=utf-8'.
func (ctx *AutheliaCtx) SetContentTypeApplicationYAML() {
ctx.SetContentTypeBytes(contentTypeApplicationYAML)
}

View File

@ -16,6 +16,62 @@ import (
"github.com/authelia/authelia/v4/internal/session"
)
func TestContentTypes(t *testing.T) {
testCases := []struct {
name string
setup func(ctx *middlewares.AutheliaCtx) (err error)
expected string
}{
{
name: "ApplicationJSON",
setup: func(ctx *middlewares.AutheliaCtx) (err error) {
ctx.SetContentTypeApplicationJSON()
return nil
},
expected: "application/json; charset=utf-8",
},
{
name: "ApplicationYAML",
setup: func(ctx *middlewares.AutheliaCtx) (err error) {
ctx.SetContentTypeApplicationYAML()
return nil
},
expected: "application/yaml; charset=utf-8",
},
{
name: "TextPlain",
setup: func(ctx *middlewares.AutheliaCtx) (err error) {
ctx.SetContentTypeTextPlain()
return nil
},
expected: "text/plain; charset=utf-8",
},
{
name: "TextHTML",
setup: func(ctx *middlewares.AutheliaCtx) (err error) {
ctx.SetContentTypeTextHTML()
return nil
},
expected: "text/html; charset=utf-8",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
assert.NoError(t, tc.setup(mock.Ctx))
assert.Equal(t, tc.expected, string(mock.Ctx.Response.Header.ContentType()))
})
}
}
func TestIssuerURL(t *testing.T) {
testCases := []struct {
name string
@ -44,8 +100,8 @@ func TestIssuerURL(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
mock.Ctx.Request.Header.Set("X-Forwarded-Proto", tc.proto)
mock.Ctx.Request.Header.Set("X-Forwarded-Host", tc.host)
mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, tc.proto)
mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, tc.host)
if tc.base != "" {
mock.Ctx.SetUserValue("base_url", tc.base)
@ -103,8 +159,8 @@ func TestShouldGetOriginalURLFromOriginalURLHeader(t *testing.T) {
func TestShouldGetOriginalURLFromForwardedHeadersWithoutURI(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https")
mock.Ctx.Request.Header.Set("X-Forwarded-Host", "home.example.com")
mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, "https")
mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, "home.example.com")
originalURL, err := mock.Ctx.GetOriginalURL()
assert.NoError(t, err)
@ -142,7 +198,7 @@ func TestShouldOnlyFallbackToNonXForwardedHeadersWhenNil(t *testing.T) {
mock.Ctx.RequestCtx.Request.SetHost("localhost")
mock.Ctx.RequestCtx.Request.Header.Set(fasthttp.HeaderXForwardedHost, "auth.example.com:1234")
mock.Ctx.RequestCtx.Request.Header.Set("X-Forwarded-URI", "/base/2fa/one-time-password")
mock.Ctx.RequestCtx.Request.Header.Set("X-Forwarded-Proto", "https")
mock.Ctx.RequestCtx.Request.Header.Set(fasthttp.HeaderXForwardedProto, "https")
mock.Ctx.RequestCtx.Request.Header.Set("X-Forwarded-Method", "GET")
assert.Equal(t, []byte("https"), mock.Ctx.XForwardedProto())

View File

@ -88,6 +88,7 @@ var (
contentTypeTextPlain = []byte("text/plain; charset=utf-8")
contentTypeTextHTML = []byte("text/html; charset=utf-8")
contentTypeApplicationJSON = []byte("application/json; charset=utf-8")
contentTypeApplicationYAML = []byte("application/yaml; charset=utf-8")
)
const (

View File

@ -6,14 +6,12 @@ import (
const (
assetsRoot = "public_html"
assetsSwagger = assetsRoot + "/api"
fileOpenAPI = "openapi.yml"
fileIndexHTML = "index.html"
fileLogo = "logo.png"
extHTML = ".html"
extJSON = ".json"
extYML = ".yml"
)
var (
@ -52,8 +50,8 @@ var (
const (
environment = "ENVIRONMENT"
dev = "dev"
f = "false"
t = "true"
strFalse = "false"
strTrue = "true"
localhost = "localhost"
schemeHTTP = "http"
schemeHTTPS = "https"
@ -76,7 +74,8 @@ X_AUTHELIA_HEALTHCHECK_PATH=%s
`
const (
tmplCSPSwagger = "default-src 'self'; img-src 'self' https://validator.swagger.io data:; object-src 'none'; script-src 'self' 'unsafe-inline' 'nonce-%s'; style-src 'self' 'nonce-%s'; base-uri 'self'"
tmplCSPSwaggerNonce = "default-src 'self'; img-src 'self' https://validator.swagger.io data:; object-src 'none'; script-src 'self' 'unsafe-inline' 'nonce-%s'; style-src 'self' 'nonce-%s'; base-uri 'self'"
tmplCSPSwagger = "default-src 'self'; img-src 'self' https://validator.swagger.io data:; object-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self'; base-uri 'self'"
)
const (

View File

@ -93,9 +93,9 @@ func handleNotFound(next fasthttp.RequestHandler) fasthttp.RequestHandler {
func handleRouter(config schema.Configuration, providers middlewares.Providers) fasthttp.RequestHandler {
optsTemplatedFile := NewTemplatedFileOptions(&config)
serveIndexHandler := ServeTemplatedFile(assetsRoot, fileIndexHTML, optsTemplatedFile)
serveSwaggerHandler := ServeTemplatedFile(assetsSwagger, fileIndexHTML, optsTemplatedFile)
serveSwaggerAPIHandler := ServeTemplatedFile(assetsSwagger, fileOpenAPI, optsTemplatedFile)
serveIndexHandler := ServeTemplatedFile(providers.Templates.GetAssetIndexTemplate(), optsTemplatedFile)
serveOpenAPIHandler := ServeTemplatedOpenAPI(providers.Templates.GetAssetOpenAPIIndexTemplate(), optsTemplatedFile)
serveOpenAPISpecHandler := ETagRootURL(ServeTemplatedOpenAPI(providers.Templates.GetAssetOpenAPISpecTemplate(), optsTemplatedFile))
handlerPublicHTML := newPublicHTMLEmbeddedHandler()
handlerLocales := newLocalesEmbeddedHandler()
@ -126,10 +126,12 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)
r.GET("/locales/{language:[a-z]{1,3}}/{namespace:[a-z]+}.json", middlewares.AssetOverride(config.Server.AssetPath, 0, handlerLocales))
// Swagger.
r.GET("/api/", middleware(serveSwaggerHandler))
r.GET("/api/", middleware(serveOpenAPIHandler))
r.OPTIONS("/api/", policyCORSPublicGET.HandleOPTIONS)
r.GET("/api/"+fileOpenAPI, policyCORSPublicGET.Middleware(middleware(serveSwaggerAPIHandler)))
r.OPTIONS("/api/"+fileOpenAPI, policyCORSPublicGET.HandleOPTIONS)
r.GET("/api/index.html", middleware(serveOpenAPIHandler))
r.OPTIONS("/api/index.html", policyCORSPublicGET.HandleOPTIONS)
r.GET("/api/openapi.yml", policyCORSPublicGET.Middleware(middleware(serveOpenAPISpecHandler)))
r.OPTIONS("/api/openapi.yml", policyCORSPublicGET.HandleOPTIONS)
for _, file := range filesSwagger {
r.GET("/api/"+file, handlerPublicHTML)

View File

@ -19,6 +19,10 @@ import (
// CreateDefaultServer Create Authelia's internal webserver with the given configuration and providers.
func CreateDefaultServer(config schema.Configuration, providers middlewares.Providers) (server *fasthttp.Server, listener net.Listener, err error) {
if err = providers.Templates.LoadTemplatedAssets(assets); err != nil {
return nil, nil, fmt.Errorf("failed to load templated assets")
}
server = &fasthttp.Server{
ErrorHandler: handleError(),
Handler: handleRouter(config, providers),

View File

@ -21,6 +21,7 @@ import (
"github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/logging"
"github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/templates"
"github.com/authelia/authelia/v4/internal/utils"
)
@ -134,10 +135,17 @@ type TLSServerContext struct {
port int
}
func NewTLSServerContext(configuration schema.Configuration) (*TLSServerContext, error) {
serverContext := new(TLSServerContext)
func NewTLSServerContext(configuration schema.Configuration) (serverContext *TLSServerContext, err error) {
serverContext = new(TLSServerContext)
s, listener, err := CreateDefaultServer(configuration, middlewares.Providers{})
providers := middlewares.Providers{}
providers.Templates, err = templates.New(templates.Config{EmailTemplatesPath: configuration.Notifier.TemplatePath})
if err != nil {
return nil, err
}
s, listener, err := CreateDefaultServer(configuration, providers)
if err != nil {
return nil, err

View File

@ -1,56 +1,43 @@
package server
import (
"bytes"
"crypto/sha1" //nolint:gosec
"encoding/hex"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"text/template"
"sync"
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/configuration/schema"
"github.com/authelia/authelia/v4/internal/logging"
"github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/templates"
"github.com/authelia/authelia/v4/internal/utils"
)
// ServeTemplatedFile serves a templated version of a specified file,
// this is utilised to pass information between the backend and frontend
// and generate a nonce to support a restrictive CSP while using material-ui.
func ServeTemplatedFile(publicDir, file string, opts *TemplatedFileOptions) middlewares.RequestHandler {
logger := logging.Logger()
a, err := assets.Open(path.Join(publicDir, file))
if err != nil {
logger.Fatalf("Unable to open %s: %s", file, err)
}
b, err := io.ReadAll(a)
if err != nil {
logger.Fatalf("Unable to read %s: %s", file, err)
}
tmpl, err := template.New("file").Parse(string(b))
if err != nil {
logger.Fatalf("Unable to parse %s template: %s", file, err)
}
func ServeTemplatedFile(t templates.Template, opts *TemplatedFileOptions) middlewares.RequestHandler {
isDevEnvironment := os.Getenv(environment) == dev
ext := filepath.Ext(t.Name())
return func(ctx *middlewares.AutheliaCtx) {
logoOverride := f
var err error
logoOverride := strFalse
if opts.AssetPath != "" {
if _, err = os.Stat(filepath.Join(opts.AssetPath, fileLogo)); err == nil {
logoOverride = t
logoOverride = strTrue
}
}
switch extension := filepath.Ext(file); extension {
switch ext {
case extHTML:
ctx.SetContentTypeTextHTML()
case extJSON:
@ -62,8 +49,6 @@ func ServeTemplatedFile(publicDir, file string, opts *TemplatedFileOptions) midd
nonce := utils.RandomString(32, utils.CharSetAlphaNumeric)
switch {
case publicDir == assetsSwagger:
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf(tmplCSPSwagger, nonce, nonce))
case ctx.Configuration.Server.Headers.CSPTemplate != "":
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, strings.ReplaceAll(ctx.Configuration.Server.Headers.CSPTemplate, placeholderCSPNonce, nonce))
case isDevEnvironment:
@ -72,15 +57,99 @@ func ServeTemplatedFile(publicDir, file string, opts *TemplatedFileOptions) midd
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf(tmplCSPDefault, nonce))
}
if err = tmpl.Execute(ctx.Response.BodyWriter(), opts.CommonData(ctx.BasePath(), ctx.RootURLSlash().String(), nonce, logoOverride)); err != nil {
if err = t.Execute(ctx.Response.BodyWriter(), opts.CommonData(ctx.BasePath(), ctx.RootURLSlash().String(), nonce, logoOverride)); err != nil {
ctx.RequestCtx.Error("an error occurred", 503)
logger.Errorf("Unable to execute template: %v", err)
ctx.Logger.WithError(err).Errorf("Error occcurred rendering template")
return
}
}
}
// ServeTemplatedOpenAPI serves templated OpenAPI related files.
func ServeTemplatedOpenAPI(t templates.Template, opts *TemplatedFileOptions) middlewares.RequestHandler {
ext := filepath.Ext(t.Name())
spec := ext == extYML
return func(ctx *middlewares.AutheliaCtx) {
var nonce string
if spec {
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, tmplCSPSwagger)
} else {
nonce = utils.RandomString(32, utils.CharSetAlphaNumeric)
ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf(tmplCSPSwaggerNonce, nonce, nonce))
}
switch ext {
case extHTML:
ctx.SetContentTypeTextHTML()
case extYML:
ctx.SetContentTypeApplicationYAML()
default:
ctx.SetContentTypeTextPlain()
}
var err error
if err = t.Execute(ctx.Response.BodyWriter(), opts.OpenAPIData(ctx.BasePath(), ctx.RootURLSlash().String(), nonce)); err != nil {
ctx.RequestCtx.Error("an error occurred", 503)
ctx.Logger.WithError(err).Errorf("Error occcurred rendering template")
return
}
}
}
// ETagRootURL dynamically matches the If-None-Match header and adds the ETag header.
func ETagRootURL(next middlewares.RequestHandler) middlewares.RequestHandler {
etags := map[string][]byte{}
h := sha1.New() //nolint:gosec // Usage is for collision avoidance not security.
mu := &sync.Mutex{}
return func(ctx *middlewares.AutheliaCtx) {
k := ctx.RootURLSlash().String()
mu.Lock()
etag, ok := etags[k]
mu.Unlock()
if ok && bytes.Equal(etag, ctx.Request.Header.PeekBytes(headerIfNoneMatch)) {
ctx.Response.Header.SetBytesKV(headerETag, etag)
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueCacheControlETaggedAssets)
ctx.SetStatusCode(fasthttp.StatusNotModified)
return
}
next(ctx)
mu.Lock()
h.Write(ctx.Response.Body())
sum := h.Sum(nil)
h.Reset()
etagNew := make([]byte, hex.EncodedLen(len(sum)))
hex.Encode(etagNew, sum)
if !ok || !bytes.Equal(etag, etagNew) {
etags[k] = etagNew
}
mu.Unlock()
ctx.Response.Header.SetBytesKV(headerETag, etagNew)
ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueCacheControlETaggedAssets)
}
}
func writeHealthCheckEnv(disabled bool, scheme, host, path string, port int) (err error) {
if disabled {
return nil
@ -120,11 +189,17 @@ func writeHealthCheckEnv(disabled bool, scheme, host, path string, port int) (er
func NewTemplatedFileOptions(config *schema.Configuration) (opts *TemplatedFileOptions) {
opts = &TemplatedFileOptions{
AssetPath: config.Server.AssetPath,
DuoSelfEnrollment: f,
DuoSelfEnrollment: strFalse,
RememberMe: strconv.FormatBool(config.Session.RememberMeDuration != schema.RememberMeDisabled),
ResetPassword: strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable),
ResetPasswordCustomURL: config.AuthenticationBackend.PasswordReset.CustomURL.String(),
Theme: config.Theme,
EndpointsPasswordReset: !(config.AuthenticationBackend.PasswordReset.Disable || config.AuthenticationBackend.PasswordReset.CustomURL.String() != ""),
EndpointsWebauthn: !config.Webauthn.Disable,
EndpointsTOTP: !config.TOTP.Disable,
EndpointsDuo: !config.DuoAPI.Disable,
EndpointsOpenIDConnect: !(config.IdentityProviders.OIDC == nil),
}
if !config.DuoAPI.Disable {
@ -143,6 +218,12 @@ type TemplatedFileOptions struct {
ResetPasswordCustomURL string
Session string
Theme string
EndpointsPasswordReset bool
EndpointsWebauthn bool
EndpointsTOTP bool
EndpointsDuo bool
EndpointsOpenIDConnect bool
}
// CommonData returns a TemplatedFileCommonData with the dynamic options.
@ -161,6 +242,22 @@ func (options *TemplatedFileOptions) CommonData(base, baseURL, nonce, logoOverri
}
}
// OpenAPIData returns a TemplatedFileOpenAPIData with the dynamic options.
func (options *TemplatedFileOptions) OpenAPIData(base, baseURL, nonce string) TemplatedFileOpenAPIData {
return TemplatedFileOpenAPIData{
Base: base,
BaseURL: baseURL,
CSPNonce: nonce,
Session: options.Session,
PasswordReset: options.EndpointsPasswordReset,
Webauthn: options.EndpointsWebauthn,
TOTP: options.EndpointsTOTP,
Duo: options.EndpointsDuo,
OpenIDConnect: options.EndpointsOpenIDConnect,
}
}
// TemplatedFileCommonData is a struct which is used for many templated files.
type TemplatedFileCommonData struct {
Base string
@ -174,3 +271,16 @@ type TemplatedFileCommonData struct {
Session string
Theme string
}
// TemplatedFileOpenAPIData is a struct which is used for the OpenAPI spec file.
type TemplatedFileOpenAPIData struct {
Base string
BaseURL string
CSPNonce string
Session string
PasswordReset bool
Webauthn bool
TOTP bool
Duo bool
OpenIDConnect bool
}

View File

@ -8,7 +8,6 @@ const (
// Template File Names.
const (
TemplateNameEmailIdentityVerification = "IdentityVerification"
TemplateNameEmailPasswordReset = "PasswordReset"
TemplateNameEmailEvent = "Event"
)

View File

@ -10,6 +10,8 @@ import (
"fmt"
"hash"
"os"
"path"
"path/filepath"
"reflect"
"sort"
"strconv"
@ -49,9 +51,97 @@ func FuncMap() map[string]any {
"b64dec": FuncB64Dec,
"b32enc": FuncB32Enc,
"b32dec": FuncB32Dec,
"list": FuncList,
"dict": FuncDict,
"get": FuncGet,
"set": FuncSet,
"isAbs": path.IsAbs,
"base": path.Base,
"dir": path.Dir,
"ext": path.Ext,
"clean": path.Clean,
"osBase": filepath.Base,
"osClean": filepath.Clean,
"osDir": filepath.Dir,
"osExt": filepath.Ext,
"osIsAbs": filepath.IsAbs,
"deepEqual": reflect.DeepEqual,
"typeOf": FuncTypeOf,
"typeIs": FuncTypeIs,
"typeIsLike": FuncTypeIsLike,
"kindOf": FuncKindOf,
"kindIs": FuncKindIs,
}
}
// FuncTypeIs is a helper function that provides similar functionality to the helm typeIs func.
func FuncTypeIs(is string, v any) bool {
return is == FuncTypeOf(v)
}
// FuncTypeIsLike is a helper function that provides similar functionality to the helm typeIsLike func.
func FuncTypeIsLike(is string, v any) bool {
t := FuncTypeOf(v)
return is == t || "*"+is == t
}
// FuncTypeOf is a helper function that provides similar functionality to the helm typeOf func.
func FuncTypeOf(v any) string {
return reflect.ValueOf(v).Type().String()
}
// FuncKindIs is a helper function that provides similar functionality to the helm kindIs func.
func FuncKindIs(is string, v any) bool {
return is == FuncKindOf(v)
}
// FuncKindOf is a helper function that provides similar functionality to the helm kindOf func.
func FuncKindOf(v any) string {
return reflect.ValueOf(v).Kind().String()
}
// FuncList is a helper function that provides similar functionality to the helm list func.
func FuncList(items ...any) []any {
return items
}
// FuncDict is a helper function that provides similar functionality to the helm dict func.
func FuncDict(pairs ...any) map[string]any {
m := map[string]any{}
p := len(pairs)
for i := 0; i < p; i += 2 {
key := strval(pairs[i])
if i+1 >= p {
m[key] = ""
continue
}
m[key] = pairs[i+1]
}
return m
}
// FuncGet is a helper function that provides similar functionality to the helm get func.
func FuncGet(m map[string]any, key string) any {
if val, ok := m[key]; ok {
return val
}
return ""
}
// FuncSet is a helper function that provides similar functionality to the helm set func.
func FuncSet(m map[string]any, key string, value any) map[string]any {
m[key] = value
return m
}
// FuncB64Enc is a helper function that provides similar functionality to the helm b64enc func.
func FuncB64Enc(input string) string {
return base64.StdEncoding.EncodeToString([]byte(input))
@ -202,7 +292,7 @@ func FuncStringQuote(in ...any) string {
return strings.Join(out, " ")
}
func strval(v interface{}) string {
func strval(v any) string {
switch v := v.(type) {
case string:
return v
@ -219,7 +309,7 @@ func strslice(v any) []string {
switch v := v.(type) {
case []string:
return v
case []interface{}:
case []any:
b := make([]string, 0, len(v))
for _, s := range v {

View File

@ -469,3 +469,97 @@ func TestFuncStringSQuote(t *testing.T) {
})
}
}
func TestFuncTypeOf(t *testing.T) {
astring := "typeOfExample"
anint := 5
astringslice := []string{astring}
anintslice := []int{anint}
testCases := []struct {
name string
have any
expected string
expectedKind string
}{
{"String", astring, "string", "string"},
{"StringPtr", &astring, "*string", "ptr"},
{"StringSlice", astringslice, "[]string", "slice"},
{"StringSlicePtr", &astringslice, "*[]string", "ptr"},
{"Integer", anint, "int", "int"},
{"IntegerPtr", &anint, "*int", "ptr"},
{"IntegerSlice", anintslice, "[]int", "slice"},
{"IntegerSlicePtr", &anintslice, "*[]int", "ptr"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, FuncTypeOf(tc.have))
assert.Equal(t, tc.expectedKind, FuncKindOf(tc.have))
})
}
}
func TestFuncTypeIs(t *testing.T) {
astring := "typeIsExample"
anint := 10
astringslice := []string{astring}
anintslice := []int{anint}
testCases := []struct {
name string
is string
have any
expected bool
expectedLike bool
expectedKind bool
}{
{"ShouldMatchStringAsString", "string", astring, true, true, true},
{"ShouldMatchStringPtrAsString", "string", &astring, false, true, false},
{"ShouldNotMatchStringAsInt", "int", astring, false, false, false},
{"ShouldNotMatchStringSliceAsStringSlice", "[]string", astringslice, true, true, false},
{"ShouldNotMatchStringSlicePtrAsStringSlice", "[]string", &astringslice, false, true, false},
{"ShouldNotMatchStringSlicePtrAsStringSlicePtr", "*[]string", &astringslice, true, true, false},
{"ShouldNotMatchStringSliceAsString", "string", astringslice, false, false, false},
{"ShouldMatchIntAsInt", "int", anint, true, true, true},
{"ShouldMatchIntPtrAsInt", "int", &anint, false, true, false},
{"ShouldNotMatchIntAsString", "string", anint, false, false, false},
{"ShouldMatchIntegerSliceAsIntSlice", "[]int", anintslice, true, true, false},
{"ShouldMatchIntegerSlicePtrAsIntSlice", "[]int", &anintslice, false, true, false},
{"ShouldMatchIntegerSlicePtrAsIntSlicePtr", "*[]int", &anintslice, true, true, false},
{"ShouldNotMatchIntegerSliceAsInt", "int", anintslice, false, false, false},
{"ShouldMatchKindSlice", "slice", anintslice, false, false, true},
{"ShouldMatchKindPtr", "ptr", &anintslice, false, false, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, FuncTypeIs(tc.is, tc.have))
assert.Equal(t, tc.expectedLike, FuncTypeIsLike(tc.is, tc.have))
assert.Equal(t, tc.expectedKind, FuncKindIs(tc.is, tc.have))
})
}
}
func TestFuncList(t *testing.T) {
assert.Equal(t, []any{"a", "b", "c"}, FuncList("a", "b", "c"))
assert.Equal(t, []any{1, 2, 3}, FuncList(1, 2, 3))
}
func TestFuncDict(t *testing.T) {
assert.Equal(t, map[string]any{"a": 1}, FuncDict("a", 1))
assert.Equal(t, map[string]any{"a": 1, "b": ""}, FuncDict("a", 1, "b"))
assert.Equal(t, map[string]any{"1": 1, "b": 2}, FuncDict(1, 1, "b", 2))
assert.Equal(t, map[string]any{"true": 1, "b": 2}, FuncDict(true, 1, "b", 2))
assert.Equal(t, map[string]any{"a": 2, "b": 3}, FuncDict("a", 1, "a", 2, "b", 3))
}
func TestFuncGet(t *testing.T) {
assert.Equal(t, 123, FuncGet(map[string]any{"abc": 123}, "abc"))
assert.Equal(t, "", FuncGet(map[string]any{"abc": 123}, "123"))
}
func TestFuncSet(t *testing.T) {
assert.Equal(t, map[string]any{"abc": 123, "123": true}, FuncSet(map[string]any{"abc": 123}, "123", true))
assert.Equal(t, map[string]any{"abc": true}, FuncSet(map[string]any{"abc": 123}, "abc", true))
}

View File

@ -1,7 +1,9 @@
package templates
import (
"embed"
"fmt"
"text/template"
)
// New creates a new templates' provider.
@ -23,6 +25,64 @@ type Provider struct {
templates Templates
}
// LoadTemplatedAssets takes an embed.FS and loads each templated asset document into a Template.
func (p *Provider) LoadTemplatedAssets(fs embed.FS) (err error) {
var (
data []byte
)
if data, err = fs.ReadFile("public_html/index.html"); err != nil {
return err
}
if p.templates.asset.index, err = template.
New("assets/public_html/index.html").
Funcs(FuncMap()).
Parse(string(data)); err != nil {
return err
}
if data, err = fs.ReadFile("public_html/api/index.html"); err != nil {
return err
}
if p.templates.asset.api.index, err = template.
New("assets/public_html/api/index.html").
Funcs(FuncMap()).
Parse(string(data)); err != nil {
return err
}
if data, err = fs.ReadFile("public_html/api/openapi.yml"); err != nil {
return err
}
if p.templates.asset.api.spec, err = template.
New("api/public_html/openapi.yaml").
Funcs(FuncMap()).
Parse(string(data)); err != nil {
return err
}
return nil
}
// GetAssetIndexTemplate returns a Template used to generate the React index document.
func (p *Provider) GetAssetIndexTemplate() (t Template) {
return p.templates.asset.index
}
// GetAssetOpenAPIIndexTemplate returns a Template used to generate the OpenAPI index document.
func (p *Provider) GetAssetOpenAPIIndexTemplate() (t Template) {
return p.templates.asset.api.index
}
// GetAssetOpenAPISpecTemplate returns a Template used to generate the OpenAPI specification document.
func (p *Provider) GetAssetOpenAPISpecTemplate() (t Template) {
return p.templates.asset.api.spec
}
// GetEventEmailTemplate returns an EmailTemplate used for generic event notifications.
func (p *Provider) GetEventEmailTemplate() (t *EmailTemplate) {
return p.templates.notification.event
}

View File

@ -9,6 +9,17 @@ import (
// Templates is the struct which holds all the *template.Template values.
type Templates struct {
notification NotificationTemplates
asset AssetTemplates
}
type AssetTemplates struct {
index *tt.Template
api APIAssetTemplates
}
type APIAssetTemplates struct {
index *tt.Template
spec *tt.Template
}
// NotificationTemplates are the templates for the notification system.

View File

@ -39,24 +39,17 @@ func isSecretEnvKey(key string) (isSecretEnvKey bool) {
return false
}
func templateExists(path string) (exists bool) {
func fileExists(path string) (exists bool) {
info, err := os.Stat(path)
if err != nil {
return false
}
if info.IsDir() {
return false
}
return true
return err == nil && !info.IsDir()
}
func readTemplate(name, ext, category, overridePath string) (tPath string, embed bool, data []byte, err error) {
if overridePath != "" {
tPath = filepath.Join(overridePath, name+ext)
if templateExists(tPath) {
if fileExists(tPath) {
if data, err = os.ReadFile(tPath); err != nil {
return tPath, false, nil, fmt.Errorf("failed to read template override at path '%s': %w", tPath, err)
}