diff --git a/api/openapi.yml b/api/openapi.yml index c5751858f..d8fa99370 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -20,7 +20,9 @@ tags: - name: State description: Configuration, health and state endpoints - name: Authentication - description: Authentication and verification endpoints + description: Authentication endpoints + - name: Authorization + description: Authorization endpoints {{- if .PasswordReset }} - name: Password Reset description: Password reset endpoints @@ -101,18 +103,58 @@ paths: application/json: schema: $ref: '#/components/schemas/handlers.StateResponse' - /api/verify: + {{- range $name, $config := .EndpointsAuthz }} + {{- $uri := printf "/api/authz/%s" $name }} + {{- if (eq $name "legacy") }}{{ $uri = "/api/verify" }}{{ end }} + {{ $uri }}: + {{- if (eq $config.Implementation "Legacy") }} {{- range $method := list "get" "head" "options" "post" "put" "patch" "delete" "trace" }} {{ $method }}: tags: - - Authentication - summary: Verification + - Authorization + summary: Authorization Verification (Legacy) description: > - The verify endpoint provides the ability to verify if a user has the necessary permissions to access a specified - domain. + The legacy authorization verification endpoint provides the ability to verify if a user has the necessary + permissions to access a specified domain with several proxies. It's generally recommended users use a proxy + specific endpoint instead. parameters: - - $ref: '#/components/parameters/originalURLParam' + - name: X-Original-URL + in: header + description: Redirection URL + required: false + style: simple + explode: true + schema: + type: string - $ref: '#/components/parameters/forwardedMethodParam' + - name: X-Forwarded-Proto + in: header + description: Redirection URL (Scheme / Protocol) + required: false + style: simple + explode: true + example: "https" + schema: + type: string + - name: X-Forwarded-Host + in: header + description: Redirection URL (Host) + required: false + style: simple + explode: true + example: "example.com" + schema: + type: string + - name: X-Forwarded-Uri + in: header + description: Redirection URL (URI) + required: false + style: simple + explode: true + example: "/path/example" + schema: + type: string + - $ref: '#/components/parameters/forwardedForParam' - $ref: '#/components/parameters/authParam' responses: "200": @@ -143,6 +185,136 @@ paths: security: - authelia_auth: [] {{- end }} + {{- else if (eq $config.Implementation "ExtAuthz") }} + {{- range $method := list "get" "head" "options" "post" "put" "patch" "delete" "trace" }} + {{ $method }}: + tags: + - Authorization + summary: Authorization Verification (ExtAuthz) + description: > + The ExtAuthz authorization verification endpoint provides the ability to verify if a user has the necessary + permissions to access a specified resource with the Envoy proxy. + parameters: + - $ref: '#/components/parameters/forwardedMethodParam' + - $ref: '#/components/parameters/forwardedHostParam' + - $ref: '#/components/parameters/forwardedURIParam' + - $ref: '#/components/parameters/forwardedForParam' + - $ref: '#/components/parameters/autheliaURLParam' + 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: [] + {{- end }} + {{- else if (eq $config.Implementation "ForwardAuth") }} + {{- range $method := list "get" "head" }} + {{ $method }}: + tags: + - Authorization + summary: Authorization Verification (ForwardAuth) + description: > + The ForwardAuth authorization verification endpoint provides the ability to verify if a user has the necessary + permissions to access a specified resource with the Traefik, Caddy, or Skipper proxies. + parameters: + - $ref: '#/components/parameters/forwardedMethodParam' + - $ref: '#/components/parameters/forwardedHostParam' + - $ref: '#/components/parameters/forwardedURIParam' + - $ref: '#/components/parameters/forwardedForParam' + 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: [] + {{- end }} + {{- else if (eq $config.Implementation "AuthRequest") }} + {{- range $method := list "get" "head" }} + {{ $method }}: + tags: + - Authorization + summary: Authorization Verification (AuthRequest) + description: > + The AuthRequest authorization verification endpoint provides the ability to verify if a user has the necessary + permissions to access a specified resource with the HAPROXY, NGINX, or NGINX-based proxies. + parameters: + - $ref: '#/components/parameters/originalMethodParam' + - $ref: '#/components/parameters/originalURLParam' + 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: [] + {{- end }} + {{- end }} + {{- end }} /api/firstfactor: post: tags: @@ -1181,6 +1353,32 @@ components: type: integer required: true description: Numeric Webauthn Device ID + originalMethodParam: + name: X-Original-Method + in: header + description: Request Method + required: true + style: simple + explode: true + schema: + type: string + enum: + - "GET" + - "HEAD" + - "POST" + - "PUT" + - "PATCH" + - "DELETE" + - "TRACE" + - "CONNECT" + - "OPTIONS" + - "COPY" + - "LOCK" + - "MKCOL" + - "MOVE" + - "PROPFIND" + - "PROPPATCH" + - "UNLOCK" originalURLParam: name: X-Original-URL in: header @@ -1216,6 +1414,56 @@ components: - "PROPFIND" - "PROPPATCH" - "UNLOCK" + forwardedProtoParam: + name: X-Forwarded-Proto + in: header + description: Redirection URL (Scheme / Protocol) + required: true + style: simple + explode: true + example: "https" + schema: + type: string + forwardedHostParam: + name: X-Forwarded-Host + in: header + description: Redirection URL (Host) + required: true + style: simple + explode: true + example: "example.com" + schema: + type: string + forwardedURIParam: + name: X-Forwarded-Uri + in: header + description: Redirection URL (URI) + required: true + style: simple + explode: true + example: "/path/example" + schema: + type: string + forwardedForParam: + name: X-Forwarded-For + in: header + description: Clients IP address or IP address chain + required: false + style: simple + explode: true + example: "192.168.0.55,192.168.0.20" + schema: + type: string + autheliaURLParam: + name: X-Authelia-URL + in: header + description: Authelia Portal URL + required: false + style: simple + explode: true + example: "https://auth.example.com" + schema: + type: string authParam: name: auth in: query diff --git a/cmd/authelia-gen/cmd_code.go b/cmd/authelia-gen/cmd_code.go index 1359298c5..49dd6ca4a 100644 --- a/cmd/authelia-gen/cmd_code.go +++ b/cmd/authelia-gen/cmd_code.go @@ -1,18 +1,13 @@ package main import ( - "crypto/ecdsa" - "crypto/rsa" "encoding/json" "fmt" "io" "net/http" - "net/mail" - "net/url" "os" "path/filepath" "reflect" - "regexp" "strings" "time" @@ -215,116 +210,3 @@ func codeKeysRunE(cmd *cobra.Command, args []string) (err error) { return nil } - -var decodedTypes = []reflect.Type{ - reflect.TypeOf(mail.Address{}), - reflect.TypeOf(regexp.Regexp{}), - reflect.TypeOf(url.URL{}), - reflect.TypeOf(time.Duration(0)), - reflect.TypeOf(schema.Address{}), - reflect.TypeOf(rsa.PrivateKey{}), - reflect.TypeOf(ecdsa.PrivateKey{}), -} - -func containsType(needle reflect.Type, haystack []reflect.Type) (contains bool) { - for _, t := range haystack { - if needle.Kind() == reflect.Ptr { - if needle.Elem() == t { - return true - } - } else if needle == t { - return true - } - } - - return false -} - -//nolint:gocyclo -func readTags(prefix string, t reflect.Type) (tags []string) { - tags = make([]string, 0) - - if t.Kind() != reflect.Struct { - if t.Kind() == reflect.Slice { - tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, "", true), t.Elem())...) - } - - return - } - - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - - tag := field.Tag.Get("koanf") - - if tag == "" { - tags = append(tags, prefix) - - continue - } - - switch field.Type.Kind() { - case reflect.Struct: - if !containsType(field.Type, decodedTypes) { - tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, false), field.Type)...) - - continue - } - case reflect.Slice: - switch field.Type.Elem().Kind() { - case reflect.Struct: - if !containsType(field.Type.Elem(), decodedTypes) { - tags = append(tags, getKeyNameFromTagAndPrefix(prefix, tag, false)) - tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, true), field.Type.Elem())...) - - continue - } - case reflect.Slice: - tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, true), field.Type.Elem())...) - } - case reflect.Ptr: - switch field.Type.Elem().Kind() { - case reflect.Struct: - if !containsType(field.Type.Elem(), decodedTypes) { - tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, false), field.Type.Elem())...) - - continue - } - case reflect.Slice: - if field.Type.Elem().Elem().Kind() == reflect.Struct { - if !containsType(field.Type.Elem(), decodedTypes) { - tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, true), field.Type.Elem())...) - - continue - } - } - } - } - - tags = append(tags, getKeyNameFromTagAndPrefix(prefix, tag, false)) - } - - return tags -} - -func getKeyNameFromTagAndPrefix(prefix, name string, slice bool) string { - nameParts := strings.SplitN(name, ",", 2) - - if prefix == "" { - return nameParts[0] - } - - if len(nameParts) == 2 && nameParts[1] == "squash" { - return prefix - } - - if slice { - if name == "" { - return fmt.Sprintf("%s[]", prefix) - } - - return fmt.Sprintf("%s.%s[]", prefix, nameParts[0]) - } - - return fmt.Sprintf("%s.%s", prefix, nameParts[0]) -} diff --git a/cmd/authelia-gen/helpers.go b/cmd/authelia-gen/helpers.go index 09ac84e8f..cb8606537 100644 --- a/cmd/authelia-gen/helpers.go +++ b/cmd/authelia-gen/helpers.go @@ -1,11 +1,20 @@ package main import ( + "crypto/ecdsa" + "crypto/rsa" "fmt" + "net/mail" + "net/url" "path/filepath" + "reflect" + "regexp" "strings" + "time" "github.com/spf13/pflag" + + "github.com/authelia/authelia/v4/internal/configuration/schema" ) func getPFlagPath(flags *pflag.FlagSet, flagNames ...string) (fullPath string, err error) { @@ -46,3 +55,125 @@ func buildCSP(defaultSrc string, ruleSets ...[]CSPValue) string { return strings.Join(rules, "; ") } + +var decodedTypes = []reflect.Type{ + reflect.TypeOf(mail.Address{}), + reflect.TypeOf(regexp.Regexp{}), + reflect.TypeOf(url.URL{}), + reflect.TypeOf(time.Duration(0)), + reflect.TypeOf(schema.Address{}), + reflect.TypeOf(schema.X509CertificateChain{}), + reflect.TypeOf(schema.PasswordDigest{}), + reflect.TypeOf(rsa.PrivateKey{}), + reflect.TypeOf(ecdsa.PrivateKey{}), +} + +func containsType(needle reflect.Type, haystack []reflect.Type) (contains bool) { + for _, t := range haystack { + if needle.Kind() == reflect.Ptr { + if needle.Elem() == t { + return true + } + } else if needle == t { + return true + } + } + + return false +} + +//nolint:gocyclo +func readTags(prefix string, t reflect.Type) (tags []string) { + tags = make([]string, 0) + + if t.Kind() != reflect.Struct { + if t.Kind() == reflect.Slice { + tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, "", true, false), t.Elem())...) + } + + return + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + tag := field.Tag.Get("koanf") + + if tag == "" { + tags = append(tags, prefix) + + continue + } + + switch kind := field.Type.Kind(); kind { + case reflect.Struct: + if !containsType(field.Type, decodedTypes) { + tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, false, false), field.Type)...) + + continue + } + case reflect.Slice, reflect.Map: + switch field.Type.Elem().Kind() { + case reflect.Struct: + if !containsType(field.Type.Elem(), decodedTypes) { + tags = append(tags, getKeyNameFromTagAndPrefix(prefix, tag, false, false)) + tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, kind == reflect.Slice, kind == reflect.Map), field.Type.Elem())...) + + continue + } + case reflect.Slice: + tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, kind == reflect.Slice, kind == reflect.Map), field.Type.Elem())...) + } + case reflect.Ptr: + switch field.Type.Elem().Kind() { + case reflect.Struct: + if !containsType(field.Type.Elem(), decodedTypes) { + tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, false, false), field.Type.Elem())...) + + continue + } + case reflect.Slice: + if field.Type.Elem().Elem().Kind() == reflect.Struct { + if !containsType(field.Type.Elem(), decodedTypes) { + tags = append(tags, readTags(getKeyNameFromTagAndPrefix(prefix, tag, true, false), field.Type.Elem())...) + + continue + } + } + } + } + + tags = append(tags, getKeyNameFromTagAndPrefix(prefix, tag, false, false)) + } + + return tags +} + +func getKeyNameFromTagAndPrefix(prefix, name string, isSlice, isMap bool) string { + nameParts := strings.SplitN(name, ",", 2) + + if prefix == "" { + return nameParts[0] + } + + if len(nameParts) == 2 && nameParts[1] == "squash" { + return prefix + } + + switch { + case isMap: + if name == "" { + return fmt.Sprintf("%s.*", prefix) + } + + return fmt.Sprintf("%s.%s.*", prefix, nameParts[0]) + case isSlice: + if name == "" { + return fmt.Sprintf("%s[]", prefix) + } + + return fmt.Sprintf("%s.%s[]", prefix, nameParts[0]) + default: + return fmt.Sprintf("%s.%s", prefix, nameParts[0]) + } +} diff --git a/cmd/authelia-scripts/cmd/suites.go b/cmd/authelia-scripts/cmd/suites.go index be903996d..40bc76376 100644 --- a/cmd/authelia-scripts/cmd/suites.go +++ b/cmd/authelia-scripts/cmd/suites.go @@ -348,6 +348,8 @@ func runSuiteTests(suiteName string, withEnv bool) error { cmd.Env = append(cmd.Env, "HEADLESS=y") } + cmd.Env = append(cmd.Env, "SUITES_LOG_LEVEL="+log.GetLevel().String()) + testErr := cmd.Run() // If the tests failed, run the error hook. diff --git a/cmd/authelia-suites/main.go b/cmd/authelia-suites/main.go index 578f84d1b..d6ed7e344 100644 --- a/cmd/authelia-suites/main.go +++ b/cmd/authelia-suites/main.go @@ -140,9 +140,7 @@ func setupSuite(cmd *cobra.Command, args []string) { log.Fatal(err) } - err = s.SetUp(suiteTmpDirectory) - - if err != nil { + if err = s.SetUp(suiteTmpDirectory); err != nil { log.Error("Failure during environment deployment.") teardownSuite(nil, args) log.Fatal(err) diff --git a/config.template.yml b/config.template.yml index 0d966b038..7da204922 100644 --- a/config.template.yml +++ b/config.template.yml @@ -51,12 +51,6 @@ server: ## Useful to allow overriding of specific static assets. # asset_path: /config/assets/ - ## Enables the pprof endpoint. - enable_pprof: false - - ## Enables the expvars endpoint. - enable_expvars: false - ## Disables writing the health check vars to /app/.healthcheck.env which makes healthcheck.sh return exit code 0. ## This is disabled by default if either /app/.healthcheck.env or /app/healthcheck.sh do not exist. disable_healthcheck: false @@ -104,6 +98,30 @@ server: ## Idle timeout. # idle: 30s + ## Server Endpoints configuration. + ## This section is considered advanced and it SHOULD NOT be configured unless you've read the relevant documentation. + # endpoints: + ## Enables the pprof endpoint. + # enable_pprof: false + + ## Enables the expvars endpoint. + # enable_expvars: false + + ## Configure the authz endpoints. + # authz: + # forward-auth: + # implementation: ForwardAuth + # authn_strategies: [] + # ext-authz: + # implementation: ExtAuthz + # authn_strategies: [] + # auth-request: + # implementation: AuthRequest + # authn_strategies: [] + # legacy: + # implementation: Legacy + # authn_strategies: [] + ## ## Log Configuration ## @@ -505,7 +523,6 @@ authentication_backend: # variant: standard # cost: 12 - ## ## Password Policy Configuration. ## @@ -540,6 +557,23 @@ password_policy: ## Configures the minimum score allowed. min_score: 3 +## +## Privacy Policy Configuration +## +## Parameters used for displaying the privacy policy link and drawer. +privacy_policy: + + ## Enables the display of the privacy policy using the policy_url. + enabled: false + + ## Enables the display of the privacy policy drawer which requires users accept the privacy policy + ## on a per-browser basis. + require_user_acceptance: false + + ## The URL of the privacy policy document. Must be an absolute URL and must have the 'https://' scheme. + ## If the privacy policy enabled option is true, this MUST be provided. + policy_url: '' + ## ## Access Control Configuration ## diff --git a/docs/content/en/blog/pre-release-notes-4.38/index.md b/docs/content/en/blog/pre-release-notes-4.38/index.md new file mode 100644 index 000000000..7c1eb541d --- /dev/null +++ b/docs/content/en/blog/pre-release-notes-4.38/index.md @@ -0,0 +1,209 @@ +--- +title: "4.38: Pre-Release Notes" +description: "Authelia 4.38 is just around the corner. This version has several additional features and improvements to existing features. In this blog post we'll discuss the new features and roughly what it means for users." +lead: "Pre-Release Notes for 4.38" +excerpt: "Authelia 4.38 is just around the corner. This version has several additional features and improvements to existing features. In this blog post we'll discuss the new features and roughly what it means for users." +date: 2023-01-18T19:47:09+10:00 +draft: false +images: [] +categories: ["News", "Release Notes"] +tags: ["releases", "pre-release-notes"] +contributors: ["James Elliott"] +pinned: false +homepage: false +--- + +Authelia [4.38](https://github.com/authelia/authelia/milestone/17) is just around the corner. This version has several +additional features and improvements to existing features. In this blog post we'll discuss the new features and roughly +what it means for users. + +Overall this release adds several major roadmap items. It's quite a big release. We expect a few bugs here and there but +nothing major. It's one of our biggest releases to date, so while it's taken a longer time than usual it's for good +reason we think. + +We understand it's taking a bit longer than usual and people are getting anxious for their particular feature of +interest. We're trying to ensure that we sufficiently add automated tests to all of the new features in both the backend +and in the frontend via automated browser-based testing in Chromium to ensure a high quality user experience. + +As this is a larger release we're probably going to ask users to help with some experimentation. If you're comfortable +backing up your database then please keep your eyes peeled in the [chat](../../information/contact.md#chat). + +_**Note:** These features discussed in this blog post are still subject to change however they represent the most likely +outcome._ + +_**Important Note:** There are some changes in this release which deprecate older configurations. The changes should be +backwards compatible, however mistakes happen. In addition we advise making the adjustments to your configuration as +necessary as several new features will not be available or even possible without making the necessary adjustments. We +will be publishing some guides on making these adjustments on the blog in the near future, including an FAQ catered to +specific scenarios._ + +## OpenID Connect 1.0 + +As part of our ongoing effort for comprehensive support for [OpenID Connect 1.0] we'll be introducing several important +features. Please see the [roadmap](../../roadmap/active/openid-connect.md) for more information. + +##### OAuth 2.0 Pushed Authorization Requests + +Support for [RFC9126] known as [Pushed Authorization Requests] is one of the main features being added to our +[OpenID Connect 1.0] implementation in this release. + +[Pushed Authorization Requests] allows for relying parties / clients to send the Authorization Request parameters over a +back-channel and receive an opaque URI to be used as the `redirect_uri` on the standard Authorization endpoint in place +of the standard Authorization Request parameters. + +The endpoint used by this mechanism requires the relying party provides the Token Endpoint authentication parameters. + +This means the actual Authorization Request parameters are never sent in the clear over the front-channel. This helps +mitigate a few things: + +1. Enhanced privacy. This is the primary focus of this specification. +2. Part of conforming to the [OpenID Connect 1.0] specification [Financial-grade API Security Profile 1.0 (Advanced)]. +3. Reduces the attack surface by preventing an attacker from adjusting request parameters prior to the Authorization + Server receiving them. +4. Reduces the attack surface marginally as less information is available over the front-channel which is the most + likely location where an attacker would have access to information. While reducing access to information is not + a reasonable primary security method, when combined with other mechanisms present in [OpenID Connect 1.0] it is + meaningful. + +Even if an attacker gets the [Authorization Code], they are unlikely to have the `client_id` for example, and this is +required to exchange the [Authorization Code] for an [Access Token] and ID Token. + +This option can be enforced globally for users who only use relying parties which support +[Pushed Authorization Requests], or can be individually enforced for each relying party which has support. + +##### Proof Key for Code Exchange by OAuth Public Clients + +While we already support [RFC7636] commonly known as [Proof Key for Code Exchange], and support enforcement at a global +level for either public clients or all clients, we're adding a feature where administrators will be able to enforce +[Proof Key for Code Exchange] on individual clients. + +It should also be noted that [Proof Key for Code Exchange] can be used at the same time as +[OAuth 2.0 Pushed Authorization Requests](#oauth-20-pushed-authorization-requests). + +These features combined with our requirement for the HTTPS scheme are very powerful security measures. + +[RFC7636]: https://datatracker.ietf.org/doc/html/rfc7636 +[RFC9126]: https://datatracker.ietf.org/doc/html/rfc9126 + +[Proof Key for Code Exchange]: https://oauth.net/2/pkce/ +[Access Token]: https://oauth.net/2/access-tokens/ +[Authorization Code]: https://oauth.net/2/grant-types/authorization-code/ +[Financial-grade API Security Profile 1.0 (Advanced)]: https://openid.net/specs/openid-financial-api-part-2-1_0.html +[OpenID Connect 1.0]: https://openid.net/ +[OpenID Connect 1.0]: https://openid.net/ +[Pushed Authorization Requests]: https://oauth.net/2/pushed-authorization-requests/ + +## Multi-Domain Protection + +In this release we are releasing the main implementation of the Multi-Domain Protection roadmap item. +Please see the [roadmap](../../roadmap/active/openid-connect.md) for more information. + +##### Initial Implementation + +_**Important Note:** This feature at the time of this writing, will not work well with Webauthn. Steps are being taken +to address this however it will not specifically delay the release of this feature._ + +This release see's the initial implementation of multi-domain protection. Users will be able to configure more than a +single root domain for cookies provided none of them are a subdomain of another domain configured. In addition each +domain can have individual settings. + +This does not allow single sign-on between these distinct domains. When surveyed users had very low interest in this +feature and technically speaking it's not trivial to implement such a feature as a lot of critical security +considerations need to be addressed. + +In addition this feature will allow configuration based detection of the Authelia Portal URI on proxies other than +NGINX/NGINX Proxy Manager/SWAG/HAProxy with the use of the new +[Customizable Authorization Endpoints](#customizable-authorization-endpoints). This is important as it means you only +need to configure a single middleware or helper to perform automatic redirection. + +## Webauthn + +As part of our ongoing effort for comprehensive support for Webauthn we'll be introducing several important +features. Please see the [roadmap](../../roadmap/active/webauthn.md) for more information. + +##### Multiple Webauthn Credentials Per-User + +In this release we see full support for multiple Webauthn credentials. This is a fairly basic feature but getting the +frontend experience right is important to us. This is going to be supported via the +[User Control Panel](#user-dashboard--control-panel). + +## Customizable Authorization Endpoints + +For the longest time we've managed to have the `/api/verify` endpoint perform all authorization verification. This has +served us well however we've been growing out of it. This endpoint is being deprecated in favor of new customizable +per-implementation endpoints. Each existing proxy we support uses one of these distinct implementations. + +The old endpoint will still work, in fact you can technically configure an additional endpoint using the methodology of +it via the `Legacy` implementation. However this is strongly discouraged and will not intentionally have new features or +fixes (excluding security fixes) going forward. + +In addition to being able to customize them you can create your own, and completely disable support for all other +implementations in the process. Use of these new endpoints will require reconfiguration of your proxy, we plan to +release a guide for each proxy. + +## User Dashboard / Control Panel + +As part of our ongoing effort for comprehensive support for a User Dashboard / Control Panel we'll be introducing +several important features. Please see the [roadmap](../../roadmap/active/dashboard-control-panel.md) for more +information. + +##### Device Registration OTP + +Instead of the current link, in this release users will instead be sent a One Time Password, cryptographically randomly +generated by Authelia. This One Time Password will grant users a duration to perform security sensitive tasks. + +The motivation for this is that it works in more situations, and is slightly less prone to phishing. + +##### TOTP Registration + +Instead of just assuming that users have successfully registered their TOTP application, we will require users to enter +the TOTP code prior to it being saved to the database. + +## Configuration + +Several enhancements are landing for the configuration. + +##### Directories + +Users will now be able to configure a directory where all `.yml` and `.yaml` files will be loaded in lexical order. +This will not allow combining lists of items, but it will allow you to split portions of the configuration easily. + +##### Discovery + +Environment variables are being added to assist with configuration discovery, and this will be the default method for +our containers. The advantage is that since the variable will be available when execing into the container, even if +the configuration paths have changed or you've defined additional paths, the `authelia` command will know where the +files are if you properly use this variables. + +##### Templating + +The file based configuration will have access to several experimental templating filters which will assist in creating +configuration templates. The initial one will just expand *most* environment variables into the configuration. The +second will use the go template engine in a very similar way to how Helm operates. + +As these features are experimental they may break, be removed, or otherwise not operate as expected. However most of our +testing indicates they're incredibly solid. + +##### LDAP Implementation + +Several new LDAP implementations which provide defaults are being introduced in this version to assist users in +integrating their LDAP server with Authelia. + +## Miscellaneous + +Some miscellaneous notes about this release. + +##### Email Notifications + +Events triggered by users will generate new notifications sent to their inbox, for example adding a new 2FA device. + +##### Storage Import/Export + +Utility functions to assist in exporting and subsequently importing the important values in Authelia are being added and +unified in this release. + +##### Privacy Policy + +We'll be introducing a feature which allows administrators to more easily comply with the GDPR which optionally shows a +link to their individual privacy policy on the frontend, and optionally requires users to accept it before using +Authelia. diff --git a/docs/content/en/configuration/miscellaneous/privacy-policy.md b/docs/content/en/configuration/miscellaneous/privacy-policy.md new file mode 100644 index 000000000..cf57e82d4 --- /dev/null +++ b/docs/content/en/configuration/miscellaneous/privacy-policy.md @@ -0,0 +1,72 @@ +--- +title: "Privacy Policy" +description: "Privacy Policy Configuration." +lead: "This describes a section of the configuration for enabling a Privacy Policy link display." +date: 2020-02-29T01:43:59+01:00 +draft: false +images: [] +menu: + configuration: + parent: "miscellaneous" +weight: 199100 +toc: true +--- + +## Configuration + +```yaml +privacy_policy: + enabled: false + require_user_acceptance: false + policy_url: '' +``` + +## Options + +### enabled + +{{< confkey type="boolean" default="false" required="no" >}} + +Enables the display of the Privacy Policy link. + +### require_user_acceptance + +{{< confkey type="boolean" default="false" required="no" >}} + +Requires users accept per-browser the Privacy Policy via a Dialog Drawer at the bottom of the page. The fact they have +accepted is recorded and checked in the browser +[localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). + +If the user has not accepted the policy they should not be able to interact with the Authelia UI via normal means. + +Administrators who are required to abide by the [GDPR] or other privacy laws should be advised that +[OpenID Connect 1.0](../identity-providers/open-id-connect.md) clients configured with the `implicit` consent mode are +unlikely to trigger the display of the Authelia UI if the user is already authenticated. + +We wont be adding checks like this to the `implicit` consent mode when that mode in particular is unlikely to be +compliant with those laws, and that mode is not strictly compliant with the OpenID Connect 1.0 specifications. It is +therefore recommended if `require_user_acceptance` is enabled then administrators should avoid using the `implicit` +consent mode or do so at their own risk. + +### policy_url + +{{< confkey type="string" required="situational" >}} + +The privacy policy URL is a URL which optionally is displayed in the frontend linking users to the administrators +privacy policy. This is useful for users who wish to abide by laws such as the [GDPR]. +Administrators can view the particulars of what _Authelia_ collects out of the box with our +[Privacy Policy](https://www.authelia.com/privacy/#application). + +This value must be an absolute URL, and must have the `https://` scheme. + +This option is required if the [enabled](#enabled) option is true. + +[GDPR]: https://gdpr-info.eu/ + +_**Example:**_ + +```yaml +privacy_policy: + enabled: true + policy_url: 'https://www.example.com/privacy-policy' +``` diff --git a/docs/content/en/configuration/miscellaneous/server-endpoints-authz.md b/docs/content/en/configuration/miscellaneous/server-endpoints-authz.md new file mode 100644 index 000000000..49ca3c3e0 --- /dev/null +++ b/docs/content/en/configuration/miscellaneous/server-endpoints-authz.md @@ -0,0 +1,72 @@ +--- +title: "Server Authz Endpoints" +description: "Configuring the Server Authz Endpoint Settings." +lead: "Authelia supports several authorization endpoints on the internal webserver. This section describes how to configure and tune them." +date: 2022-10-31T09:33:39+11:00 +draft: false +images: [] +menu: +configuration: +parent: "miscellaneous" +weight: 199210 +toc: true +aliases: +- /c/authz +--- + +## Configuration + +```yaml +server: + endpoints: + authz: + forward-auth: + implementation: ForwardAuth + authn_strategies: [] + ext-authz: + implementation: ExtAuthz + authn_strategies: [] + auth-request: + implementation: AuthRequest + authn_strategies: [] + legacy: + implementation: Legacy + authn_strategies: [] +``` + +## Name + +{{< confkey type="string" required="yes" >}} + +The first level under the `authz` directive is the name of the endpoint. In the example these names are `forward-auth`, +`ext-authz`, `auth-request`, and `legacy`. + +The name correlates with the path of the endpoint. All endpoints start with `/api/authz/`, and end with the name. In the +example the `forward-auth` endpoint has a full path of `/api/authz/forward-auth`. + +Valid characters for the name are alphanumeric as well as `-` and `_`. They MUST start AND end with an +alphanumeric character. + +### implementation + +{{< confkey type="string" required="yes" >}} + +The underlying implementation for the endpoint. Valid case-sensitive values are `ForwardAuth`, `ExtAuthz`, +`AuthRequest`, and `Legacy`. Read more about the implementations in the +[reference guide](../../reference/guides/proxy-authorization.md#implementations). + +### authn_strategies + +{{< confkey type="list" required="no" >}} + +A list of authentication strategies and their configuration options. These strategies are in order, and the first one +which succeeds is used. Failures other than lacking the sufficient information in the request to perform the strategy +immediately short-circuit the authentication, otherwise the next strategy in the list is attempted. + +#### name + +{{< confkey type="string" required="yes" >}} + +The name of the strategy. Valid case-sensitive values are `CookieSession`, `HeaderAuthorization`, +`HeaderProxyAuthorization`, `HeaderAuthRequestProxyAuthorization`, and `HeaderLegacy`. Read more about the strategies in +the [reference guide](../../reference/guides/proxy-authorization.md#authn-strategies). diff --git a/docs/content/en/configuration/miscellaneous/server.md b/docs/content/en/configuration/miscellaneous/server.md index 39340d5bf..2eaa4d1db 100644 --- a/docs/content/en/configuration/miscellaneous/server.md +++ b/docs/content/en/configuration/miscellaneous/server.md @@ -22,8 +22,6 @@ server: host: 0.0.0.0 port: 9091 path: "" - enable_pprof: false - enable_expvars: false disable_healthcheck: false tls: key: "" @@ -38,6 +36,22 @@ server: read: 6s write: 6s idle: 30s + endpoints: + enable_pprof: false + enable_expvars: false + authz: + forward-auth: + implementation: ForwardAuth + authn_strategies: [] + ext-authz: + implementation: ExtAuthz + authn_strategies: [] + auth-request: + implementation: AuthRequest + authn_strategies: [] + legacy: + implementation: Legacy + authn_strategies: [] ``` ## Options @@ -100,18 +114,6 @@ assets that can be overridden must be placed in the `asset_path`. The structure can be overriden is documented in the [Sever Asset Overrides Reference Guide](../../reference/guides/server-asset-overrides.md). -### enable_pprof - -{{< confkey type="boolean" default="false" required="no" >}} - -Enables the go pprof endpoints. - -### enable_expvars - -{{< confkey type="boolean" default="false" required="no" >}} - -Enables the go expvars endpoints. - ### disable_healthcheck {{< confkey type="boolean" default="false" required="no" >}} @@ -177,6 +179,32 @@ information. Configures the server timeouts. See the [Server Timeouts](../prologue/common.md#server-timeouts) documentation for more information. +### endpoints + +#### enable_pprof + +{{< confkey type="boolean" default="false" required="no" >}} + +*__Security Note:__ This is a developer endpoint. __DO NOT__ enable it unless you know why you're enabling it. +__DO NOT__ enable this in production.* + +Enables the go [pprof](https://pkg.go.dev/net/http/pprof) endpoints. + +#### enable_expvars + +*__Security Note:__ This is a developer endpoint. __DO NOT__ enable it unless you know why you're enabling it. +__DO NOT__ enable this in production.* + +{{< confkey type="boolean" default="false" required="no" >}} + +Enables the go [expvar](https://pkg.go.dev/expvar) endpoints. + +#### authz + +This is an *__advanced__* option allowing configuration of the authorization endpoints and has its own section. +Generally this does not need to be configured for most use cases. See the +[authz configuration](./server-endpoints-authz.md) for more information. + ## Additional Notes ### Buffer Sizes diff --git a/docs/content/en/configuration/prologue/common.md b/docs/content/en/configuration/prologue/common.md index 2a8c8b381..a5ea1ee3f 100644 --- a/docs/content/en/configuration/prologue/common.md +++ b/docs/content/en/configuration/prologue/common.md @@ -204,4 +204,4 @@ Configures the server write timeout. *__Note:__ This setting uses the [duration notation format](#duration-notation-format). Please see the [common options](#duration-notation-format) documentation for information on this format.* -Configures the server write timeout. +Configures the server idle timeout. diff --git a/docs/content/en/contributing/development/environment.md b/docs/content/en/contributing/development/environment.md index 864e5d479..1a81643d6 100644 --- a/docs/content/en/contributing/development/environment.md +++ b/docs/content/en/contributing/development/environment.md @@ -18,18 +18,25 @@ __Authelia__ and its development workflow can be tested with [Docker] and [Docke In order to build and contribute to __Authelia__, you need to make sure the following are installed in your environment: -* [go] *(v1.18 or greater)* -* [Docker] -* [Docker Compose] -* [Node.js] *(v16 or greater)* -* [pnpm] +* General: + * [git] +* Backend Development: + * [go] *(v1.19 or greater)* + * [gcc] +* Frontend Development + * [Node.js] *(v18 or greater)* + * [pnpm] +* Integration Suites: + * [Docker] + * [Docker Compose] + * [chromium] The additional tools are recommended: * [golangci-lint] * [goimports-reviser] * [yamllint] -* Either the [VSCodium] or [GoLand] IDE +* [VSCodium] or [GoLand] ## Scripts @@ -80,3 +87,6 @@ listed subdomains from your browser, and they will be served by the reverse prox [yamllint]: https://yamllint.readthedocs.io/en/stable/quickstart.html [VSCodium]: https://vscodium.com/ [GoLand]: https://www.jetbrains.com/go/ +[chromium]: https://www.chromium.org/ +[git]: https://git-scm.com/ +[gcc]: https://gcc.gnu.org/ diff --git a/docs/content/en/integration/kubernetes/istio.md b/docs/content/en/integration/kubernetes/istio.md index 9a0d162f7..c1c4927e3 100644 --- a/docs/content/en/integration/kubernetes/istio.md +++ b/docs/content/en/integration/kubernetes/istio.md @@ -39,27 +39,23 @@ spec: envoyExtAuthzHttp: service: 'authelia.default.svc.cluster.local' port: 80 - pathPrefix: '/api/verify/' + pathPrefix: '/api/authz/ext-authz/' includeRequestHeadersInCheck: - - accept - - cookie - - proxy-authorization + - 'accept' + - 'cookie' + - 'authorization' + - 'proxy-authorization' headersToUpstreamOnAllow: - 'authorization' - 'proxy-authorization' - 'remote-*' - 'authelia-*' includeAdditionalHeadersInCheck: - X-Authelia-URL: 'https://auth.example.com/' - X-Forwarded-Method: '%REQ(:METHOD)%' X-Forwarded-Proto: '%REQ(:SCHEME)%' - X-Forwarded-Host: '%REQ(:AUTHORITY)%' - X-Forwarded-URI: '%REQ(:PATH)%' - X-Forwarded-For: '%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%' headersToDownstreamOnDeny: - - set-cookie + - 'set-cookie' headersToDownstreamOnAllow: - - set-cookie + - 'set-cookie' ``` ### Authorization Policy diff --git a/docs/content/en/integration/kubernetes/nginx-ingress.md b/docs/content/en/integration/kubernetes/nginx-ingress.md index 614626e60..d538aa860 100644 --- a/docs/content/en/integration/kubernetes/nginx-ingress.md +++ b/docs/content/en/integration/kubernetes/nginx-ingress.md @@ -41,11 +41,9 @@ be applied to the Authelia Ingress itself.* ```yaml annotations: nginx.ingress.kubernetes.io/auth-method: GET - nginx.ingress.kubernetes.io/auth-url: http://authelia.default.svc.cluster.local/api/verify + nginx.ingress.kubernetes.io/auth-url: http://authelia.default.svc.cluster.local/api/authz/auth-request nginx.ingress.kubernetes.io/auth-signin: https://auth.example.com?rm=$request_method - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email - nginx.ingress.kubernetes.io/auth-snippet: | - proxy_set_header X-Forwarded-Method $request_method; + nginx.ingress.kubernetes.io/auth-response-headers: Authorization,Proxy-Authorization,Remote-User,Remote-Name,Remote-Groups,Remote-Email ``` [ingress-nginx]: https://kubernetes.github.io/ingress-nginx/ diff --git a/docs/content/en/integration/kubernetes/traefik-ingress.md b/docs/content/en/integration/kubernetes/traefik-ingress.md index 3ada01b8e..3a50d8e9e 100644 --- a/docs/content/en/integration/kubernetes/traefik-ingress.md +++ b/docs/content/en/integration/kubernetes/traefik-ingress.md @@ -61,12 +61,17 @@ metadata: app.kubernetes.io/name: authelia spec: forwardAuth: - address: http://authelia.default.svc.cluster.local/api/verify?rd=https%3A%2F%2Fauth.example.com%2F + address: 'http://authelia.default.svc.cluster.local/api/authz/forward-auth' + ## The following commented line is for configuring the Authelia URL in the proxy. We strongly suggest this is + ## configured in the Session Cookies section of the Authelia configuration. + # address: 'http://authelia.default.svc.cluster.local/api/authz/forward-auth?authelia_url=https%3A%2F%2Fauth.example.com%2F' authResponseHeaders: - - Remote-User - - Remote-Name - - Remote-Email - - Remote-Groups + - 'Authorization' + - 'Proxy-Authorization' + - 'Remote-User' + - 'Remote-Groups' + - 'Remote-Email' + - 'Remote-Name' ... ``` {{< /details >}} diff --git a/docs/content/en/integration/proxies/caddy.md b/docs/content/en/integration/proxies/caddy.md index 904fb17df..f552e7502 100644 --- a/docs/content/en/integration/proxies/caddy.md +++ b/docs/content/en/integration/proxies/caddy.md @@ -98,7 +98,10 @@ auth.example.com { # Protected Endpoint. nextcloud.example.com { forward_auth authelia:9091 { - uri /api/verify?rd=https://auth.example.com/ + uri /api/authz/forward-auth + ## The following commented line is for configuring the Authelia URL in the proxy. We strongly suggest + ## this is configured in the Session Cookies section of the Authelia configuration. + # uri /api/authz/forward-auth?authelia_url=https://auth.example.com/ copy_headers Remote-User Remote-Groups Remote-Name Remote-Email ## This import needs to be included if you're relying on a trusted proxies configuration. @@ -137,7 +140,7 @@ example.com { @nextcloud path /nextcloud /nextcloud/* handle @nextcloud { forward_auth authelia:9091 { - uri /api/verify?rd=https://example.com/authelia/ + uri /api/authz/forward-auth?authelia_url=https://example.com/authelia/ copy_headers Remote-User Remote-Groups Remote-Name Remote-Email ## This import needs to be included if you're relying on a trusted proxies configuration. @@ -183,7 +186,7 @@ nextcloud.example.com { import trusted_proxy_list method GET - rewrite "/api/verify?rd=https://auth.example.com/" + rewrite "/api/authz/forward-auth?authelia_url=https://auth.example.com/" header_up X-Forwarded-Method {method} header_up X-Forwarded-Uri {uri} diff --git a/docs/content/en/integration/proxies/envoy.md b/docs/content/en/integration/proxies/envoy.md index b03b09a7e..25cece96d 100644 --- a/docs/content/en/integration/proxies/envoy.md +++ b/docs/content/en/integration/proxies/envoy.md @@ -169,7 +169,7 @@ static_resources: typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz http_service: - path_prefix: '/api/verify/' + path_prefix: /api/authz/ext-authz/ server_uri: uri: authelia:9091 cluster: authelia @@ -181,18 +181,12 @@ static_resources: - exact: cookie - exact: proxy-authorization headers_to_add: - - key: X-Authelia-URL - value: 'https://auth.example.com/' - - key: X-Forwarded-Method - value: '%REQ(:METHOD)%' - key: X-Forwarded-Proto value: '%REQ(:SCHEME)%' - - key: X-Forwarded-Host - value: '%REQ(:AUTHORITY)%' - - key: X-Forwarded-Uri - value: '%REQ(:PATH)%' - - key: X-Forwarded-For - value: '%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%' + ## The following commented lines are for configuring the Authelia URL in the proxy. We + ## strongly suggest this is configured in the Session Cookies section of the Authelia configuration. + # - key: X-Authelia-URL + # value: https://auth.example.com authorization_response: allowed_upstream_headers: patterns: diff --git a/docs/content/en/integration/proxies/haproxy.md b/docs/content/en/integration/proxies/haproxy.md index f5a8dd178..88c6d25f7 100644 --- a/docs/content/en/integration/proxies/haproxy.md +++ b/docs/content/en/integration/proxies/haproxy.md @@ -193,13 +193,11 @@ frontend fe_http # Required headers http-request set-header X-Real-IP %[src] - http-request set-header X-Forwarded-Method %[var(req.method)] - http-request set-header X-Forwarded-Proto %[var(req.scheme)] - http-request set-header X-Forwarded-Host %[req.hdr(Host)] - http-request set-header X-Forwarded-Uri %[path]%[var(req.questionmark)]%[query] + http-request set-header X-Original-Method %[var(req.method)] + http-request set-header X-Original-URL %[var(req.scheme)]://%[req.hdr(Host)]%[path]%[var(req.questionmark)]%[query] # Protect endpoints with haproxy-auth-request and Authelia - http-request lua.auth-request be_authelia /api/verify if protected-frontends + http-request lua.auth-request be_authelia /api/authz/auth-request if protected-frontends # Force `Authorization` header via query arg to /api/verify http-request lua.auth-request be_authelia /api/verify?auth=basic if protected-frontends-basic @@ -293,12 +291,11 @@ frontend fe_http # Required headers http-request set-header X-Real-IP %[src] - http-request set-header X-Forwarded-Proto %[var(req.scheme)] - http-request set-header X-Forwarded-Host %[req.hdr(Host)] - http-request set-header X-Forwarded-Uri %[path]%[var(req.questionmark)]%[query] + http-request set-header X-Original-Method %[var(req.method)] + http-request set-header X-Original-URL %[var(req.scheme)]://%[req.hdr(Host)]%[path]%[var(req.questionmark)]%[query] # Protect endpoints with haproxy-auth-request and Authelia - http-request lua.auth-request be_authelia_proxy /api/verify if protected-frontends + http-request lua.auth-request be_authelia_proxy /api/authz/auth-request if protected-frontends # Force `Authorization` header via query arg to /api/verify http-request lua.auth-request be_authelia_proxy /api/verify?auth=basic if protected-frontends-basic diff --git a/docs/content/en/integration/proxies/introduction.md b/docs/content/en/integration/proxies/introduction.md index a747a1fa5..4670ee6d1 100644 --- a/docs/content/en/integration/proxies/introduction.md +++ b/docs/content/en/integration/proxies/introduction.md @@ -31,21 +31,22 @@ See [support](support.md) for support information. ## Integration Implementation Authelia is capable of being integrated into many proxies due to the decisions regarding the implementation. We handle -requests to the `/api/verify` endpoint with specific headers and return standardized responses based on the headers and +requests to the authz endpoints with specific headers and return standardized responses based on the headers and the policy engines determination about what must be done. ### Destination Identification -The method to identify the destination of a request relies on metadata headers which need to be set by your reverse -proxy. The headers we rely on are as follows: +Broadly speaking, the method to identify the destination of a request relies on metadata headers which need to be set by +your reverse proxy. The headers we rely on at the authz endpoints are as follows: * [X-Forwarded-Proto](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) * [X-Forwarded-Host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host) * X-Forwarded-Uri * [X-Forwarded-For](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For) -* X-Forwarded-Method +* X-Forwarded-Method / X-Original-Method +* X-Original-URL -Alternatively we utilize `X-Original-URL` header which is expected to contain a fully formatted URL. +The specifics however are dictated by the specific [Authorization Implementation](../../reference/guides/proxy-authorization.md) used. ### User Identification diff --git a/docs/content/en/integration/proxies/nginx.md b/docs/content/en/integration/proxies/nginx.md index 683c2cf2a..7f4907b16 100644 --- a/docs/content/en/integration/proxies/nginx.md +++ b/docs/content/en/integration/proxies/nginx.md @@ -197,6 +197,10 @@ server { location /api/verify { proxy_pass $upstream; } + + location /api/authz/ { + proxy_pass $upstream; + } } ``` {{< /details >}} @@ -376,7 +380,7 @@ proxy_set_header X-Forwarded-For $remote_addr; {{< details "/config/nginx/snippets/authelia-location.conf" >}} ```nginx -set $upstream_authelia http://authelia:9091/api/verify; +set $upstream_authelia http://authelia:9091/api/authz/auth-request; ## Virtual endpoint created by nginx to forward auth requests. location /authelia { @@ -386,12 +390,8 @@ location /authelia { ## Headers ## The headers starting with X-* are required. - proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Original-Method $request_method; - proxy_set_header X-Forwarded-Method $request_method; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-Uri $request_uri; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Content-Length ""; proxy_set_header Connection ""; @@ -458,9 +458,12 @@ snippet is rarely required. It's only used if you want to only allow [HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) for a particular endpoint. It's recommended to use [authelia-location.conf](#authelia-locationconf) instead.* +_**Note:** This example assumes you configured an authz endpoint with the name `auth-request/basic` and the +implementation `AuthRequest` which contains the `HeaderAuthorization` and `HeaderProxyAuthorization` strategies._ + {{< details "/config/nginx/snippets/authelia-location-basic.conf" >}} ```nginx -set $upstream_authelia http://authelia:9091/api/verify?auth=basic; +set $upstream_authelia http://authelia:9091/api/authz/auth-request/basic; # Virtual endpoint created by nginx to forward auth requests. location /authelia-basic { @@ -470,6 +473,7 @@ location /authelia-basic { ## Headers ## The headers starting with X-* are required. + proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Forwarded-Method $request_method; diff --git a/docs/content/en/integration/proxies/support.md b/docs/content/en/integration/proxies/support.md index d74252e4e..01711ce27 100644 --- a/docs/content/en/integration/proxies/support.md +++ b/docs/content/en/integration/proxies/support.md @@ -15,19 +15,24 @@ aliases: - /docs/home/supported-proxies.html --- -| Proxy | [Standard](#standard) | [Kubernetes](#kubernetes) | [XHR Redirect](#xhr-redirect) | [Request Method](#request-method) | -|:---------------------:|:------------------------------------------------------------------:|:-------------------------------------------------------------------------------------:|:---------------------------------:|:---------------------------------:| -| [Traefik] | {{% support support="full" link="traefik.md" %}} | {{% support support="full" link="../../integration/kubernetes/traefik-ingress.md" %}} | {{% support support="full" %}} | {{% support support="full" %}} | -| [Caddy] | {{% support support="full" link="caddy.md" %}} | {{% support support="unknown" %}} | {{% support support="full" %}} | {{% support support="full" %}} | -| [Envoy] | {{% support support="full" link="envoy.md" %}} | {{% support support="full" link="../../integration/kubernetes/istio.md" %}} | {{% support support="unknown" %}} | {{% support support="full" %}} | -| [NGINX] | {{% support support="full" link="nginx.md" %}} | {{% support support="full" link="../../integration/kubernetes/nginx-ingress.md" %}} | {{% support %}} | {{% support support="full" %}} | -| [NGINX Proxy Manager] | {{% support support="full" link="nginx-proxy-manager/index.md" %}} | {{% support support="unknown" %}} | {{% support %}} | {{% support support="full" %}} | -| [SWAG] | {{% support support="full" link="swag.md" %}} | {{% support support="unknown" %}} | {{% support %}} | {{% support support="full" %}} | -| [HAProxy] | {{% support support="full" link="haproxy.md" %}} | {{% support support="unknown" %}} | {{% support support="unknown" %}} | {{% support support="full" %}} | -| [Skipper] | {{% support support="full" link="skipper.md" %}} | {{% support support="unknown" %}} | {{% support support="unknown" %}} | {{% support support="unknown" %}} | -| [Traefik] 1.x | {{% support support="full" link="traefikv1.md" %}} | {{% support support="unknown" %}} | {{% support support="full" %}} | {{% support support="full" %}} | -| [Apache] | {{% support link="#apache" %}} | {{% support %}} | {{% support %}} | {{% support %}} | -| [IIS] | {{% support link="#iis" %}} | {{% support %}} | {{% support %}} | {{% support %}} | +| Proxy | [Implementation] | [Standard](#standard) | [Kubernetes](#kubernetes) | [XHR Redirect](#xhr-redirect) | [Request Method](#request-method) | +|:---------------------:|:----------------:|:------------------------------------------------------------------:|:-------------------------------------------------------------------------------------:|:---------------------------------:|:---------------------------------:| +| [Traefik] | [ForwardAuth] | {{% support support="full" link="traefik.md" %}} | {{% support support="full" link="../../integration/kubernetes/traefik-ingress.md" %}} | {{% support support="full" %}} | {{% support support="full" %}} | +| [Caddy] | [ForwardAuth] | {{% support support="full" link="caddy.md" %}} | {{% support support="unknown" %}} | {{% support support="full" %}} | {{% support support="full" %}} | +| [Envoy] | [ExtAuthz] | {{% support support="full" link="envoy.md" %}} | {{% support support="full" link="../../integration/kubernetes/istio.md" %}} | {{% support support="unknown" %}} | {{% support support="full" %}} | +| [NGINX] | [AuthRequest] | {{% support support="full" link="nginx.md" %}} | {{% support support="full" link="../../integration/kubernetes/nginx-ingress.md" %}} | {{% support %}} | {{% support support="full" %}} | +| [NGINX Proxy Manager] | [AuthRequest] | {{% support support="full" link="nginx-proxy-manager/index.md" %}} | {{% support support="unknown" %}} | {{% support %}} | {{% support support="full" %}} | +| [SWAG] | [AuthRequest] | {{% support support="full" link="swag.md" %}} | {{% support support="unknown" %}} | {{% support %}} | {{% support support="full" %}} | +| [HAProxy] | [AuthRequest] | {{% support support="full" link="haproxy.md" %}} | {{% support support="unknown" %}} | {{% support support="unknown" %}} | {{% support support="full" %}} | +| [Skipper] | [ForwardAuth] | {{% support support="full" link="skipper.md" %}} | {{% support support="unknown" %}} | {{% support support="unknown" %}} | {{% support support="unknown" %}} | +| [Traefik] 1.x | [ForwardAuth] | {{% support support="full" link="traefikv1.md" %}} | {{% support support="unknown" %}} | {{% support support="full" %}} | {{% support support="full" %}} | +| [Apache] | N/A | {{% support link="#apache" %}} | {{% support %}} | {{% support %}} | {{% support %}} | +| [IIS] | N/A | {{% support link="#iis" %}} | {{% support %}} | {{% support %}} | {{% support %}} | + +[ForwardAuth]: ../../reference/guides/proxy-authorization.md#forwardauth +[AuthRequest]: ../../reference/guides/proxy-authorization.md#authrequest +[ExtAuthz]: ../../reference/guides/proxy-authorization.md#extauthz +[Implementation]: ../../reference/guides/proxy-authorization.md#implementations Legend: diff --git a/docs/content/en/integration/proxies/traefik.md b/docs/content/en/integration/proxies/traefik.md index cc9317e97..0eed9e4d4 100644 --- a/docs/content/en/integration/proxies/traefik.md +++ b/docs/content/en/integration/proxies/traefik.md @@ -152,12 +152,12 @@ services: - 'traefik.http.routers.authelia.rule=Host(`auth.example.com`)' - 'traefik.http.routers.authelia.entryPoints=https' - 'traefik.http.routers.authelia.tls=true' - - 'traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/verify?rd=https%3A%2F%2Fauth.example.com%2F' + - 'traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/authz/forward-auth' + ## The following commented line is for configuring the Authelia URL in the proxy. We strongly suggest this is + ## configured in the Session Cookies section of the Authelia configuration. + # - 'traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/authz/forward-auth?authelia_url=https%3A%2F%2Fauth.example.com%2F' - 'traefik.http.middlewares.authelia.forwardAuth.trustForwardHeader=true' - - 'traefik.http.middlewares.authelia.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' - - 'traefik.http.middlewares.authelia-basic.forwardAuth.address=http://authelia:9091/api/verify?auth=basic' - - 'traefik.http.middlewares.authelia-basic.forwardAuth.trustForwardHeader=true' - - 'traefik.http.middlewares.authelia-basic.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' + - 'traefik.http.middlewares.authelia.forwardAuth.authResponseHeaders=Authorization,Proxy-Authorization,Remote-User,Remote-Groups,Remote-Name,Remote-Email' nextcloud: container_name: nextcloud image: linuxserver/nextcloud @@ -364,26 +364,30 @@ http: middlewares: authelia: forwardAuth: - address: https://authelia:9091/api/verify?rd=https%3A%2F%2Fauth.example.com%2F + address: 'https://authelia:9091/api/authz/forward-auth?authelia_url=https%3A%2F%2Fauth.example.com%2F' trustForwardHeader: true authResponseHeaders: - - "Remote-User" - - "Remote-Groups" - - "Remote-Email" - - "Remote-Name" + - 'Authorization' + - 'Proxy-Authorization' + - 'Remote-User' + - 'Remote-Groups' + - 'Remote-Email' + - 'Remote-Name' tls: ca: /certificates/ca.public.crt cert: /certificates/traefik.public.crt key: /certificates/traefik.private.pem authelia-basic: forwardAuth: - address: https://authelia:9091/api/verify?auth=basic + address: 'https://authelia:9091/api/verify?auth=basic' trustForwardHeader: true authResponseHeaders: - - "Remote-User" - - "Remote-Groups" - - "Remote-Email" - - "Remote-Name" + - 'Authorization' + - 'Proxy-Authorization' + - 'Remote-User' + - 'Remote-Groups' + - 'Remote-Email' + - 'Remote-Name' tls: ca: /certificates/ca.public.crt cert: /certificates/traefik.public.crt @@ -491,9 +495,12 @@ This can be avoided a couple different ways: 2. Define the __Authelia__ middleware on your [Traefik] container. See the below example. ```yaml -- 'traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/verify?rd=https%3A%2F%2Fauth.example.com%2F' +- 'traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/authz/forward-auth' +## The following commented line is for configuring the Authelia URL in the proxy. We strongly suggest this is +## configured in the Session Cookies section of the Authelia configuration. +# - 'traefik.http.middlewares.authelia.forwardAuth.address=http://authelia:9091/api/authz/forward-auth?authelia_url=https%3A%2F%2Fauth.example.com%2F' - 'traefik.http.middlewares.authelia.forwardAuth.trustForwardHeader=true' -- 'traefik.http.middlewares.authelia.forwardAuth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' +- 'traefik.http.middlewares.authelia.forwardAuth.authResponseHeaders=Authorization,Proxy-Authorization,Remote-User,Remote-Groups,Remote-Name,Remote-Email' ``` ## See Also diff --git a/docs/content/en/integration/proxies/traefikv1.md b/docs/content/en/integration/proxies/traefikv1.md index 1867bf3b1..7a2273ae3 100644 --- a/docs/content/en/integration/proxies/traefikv1.md +++ b/docs/content/en/integration/proxies/traefikv1.md @@ -90,9 +90,9 @@ services: - 'traefik.frontend.rule=Host:traefik.example.com' - 'traefik.port=8081' ports: - - 80:80 - - 443:443 - - 8081:8081 + - '80:80' + - '443:443' + - '8081:8081' restart: unless-stopped command: - '--api' @@ -132,9 +132,12 @@ services: - net labels: - 'traefik.frontend.rule=Host:nextcloud.example.com' - - 'traefik.frontend.auth.forward.address=http://authelia:9091/api/verify?rd=https://auth.example.com/' + - 'traefik.frontend.auth.forward.address=http://authelia:9091/api/authz/forward-auth' + ## The following commented line is for configuring the Authelia URL in the proxy. We strongly suggest this is + ## configured in the Session Cookies section of the Authelia configuration. + # - 'traefik.frontend.auth.forward.address=http://authelia:9091/api/authz/forward-auth?authelia_url=https%3A%2F%2Fauth.example.com%2F' - 'traefik.frontend.auth.forward.trustForwardHeader=true' - - 'traefik.frontend.auth.forward.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' + - 'traefik.frontend.auth.forward.authResponseHeaders=Authorization,Proxy-Authorization,Remote-User,Remote-Groups,Remote-Name,Remote-Email' expose: - 443 restart: unless-stopped @@ -151,9 +154,9 @@ services: - net labels: - 'traefik.frontend.rule=Host:heimdall.example.com' - - 'traefik.frontend.auth.forward.address=http://authelia:9091/api/verify?auth=basic' + - 'traefik.frontend.auth.forward.address=http://authelia:9091/api/authz/forward-auth/basic' - 'traefik.frontend.auth.forward.trustForwardHeader=true' - - 'traefik.frontend.auth.forward.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' + - 'traefik.frontend.auth.forward.authResponseHeaders=Authorization,Proxy-Authorization,Remote-User,Remote-Groups,Remote-Name,Remote-Email' expose: - 443 restart: unless-stopped diff --git a/docs/content/en/reference/guides/metrics.md b/docs/content/en/reference/guides/metrics.md index a6020a4dd..fd7371392 100644 --- a/docs/content/en/reference/guides/metrics.md +++ b/docs/content/en/reference/guides/metrics.md @@ -25,22 +25,21 @@ when configured. If metrics are enabled the metrics listener listens on `0.0.0.0 #### Recorded Metrics -##### Vectored Histograms - -| Name | Vectors | Buckets | -|:-----------------------:|:-------:|:-------------------------------------------------------------------------------------------------------------:| -| authentication_duration | success | .0005, .00075, .001, .005, .01, .025, .05, .075, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 0.9, 1, 5, 10, 15, 30, 60 | -| request_duration | code | .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 15, 20, 30, 40, 50, 60 | - ##### Vectored Counters -| Name | Vectors | -|:----------------------------:|:---------------------:| -| request | code, method | -| verify_request | code | -| authentication_first_factor | success, banned | -| authentication_second_factor | success, banned, type | +| Name | Vectors | Description | +|:-------------------:|:---------------------:|:--------------------:| +| request | code, method | All Requests | +| authz | code | Authz Requests | +| authn | success, banned | Authn Requests (1FA) | +| authn_second_factor | success, banned, type | Authn Requests (2FA) | +##### Vectored Histograms + +| Name | Vectors | Buckets | +|:----------------:|:-------:|:-------------------------------------------------------------------------------------------------------------:| +| authn_duration | success | .0005, .00075, .001, .005, .01, .025, .05, .075, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 0.9, 1, 5, 10, 15, 30, 60 | +| request_duration | code | .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 15, 20, 30, 40, 50, 60 | #### Vector Definitions diff --git a/docs/content/en/reference/guides/proxy-authorization.md b/docs/content/en/reference/guides/proxy-authorization.md new file mode 100644 index 000000000..22420a13d --- /dev/null +++ b/docs/content/en/reference/guides/proxy-authorization.md @@ -0,0 +1,247 @@ +--- +title: "Proxy Authorization" +description: "A reference guide on Proxy Authorization implementations" +lead: "This section contains reference guide on Proxy Authorization implementations Authelia supports." +date: 2022-10-31T09:33:39+11:00 +draft: false +images: [] +menu: +reference: +parent: "guides" +weight: 220 +toc: true +aliases: +- /r/proxy-authz +--- + +Proxies can integrate with Authelia via several authorization endpoints. These endpoints are by default configured +appropriately for most use cases; however they can be individually configured, removed, added, etc. + +They are currently divided into two sections: + +- [Implementations](#implementations) +- [Authn Strategies](#authn-strategies) + +These endpoints are meant to collect important information from these requests via headers to determine both +metadata about the request (such as the resource and IP address of the user) which is determined via the +[Implementations](#implementations), and the identity of the user which is determined via the +[Authn Strategies](#authn-strategies). + +## Default Endpoints + +| Name | Path | [Implementation] | [Authn Strategies] | +|:------------:|:-----------------------:|:----------------:|:------------------------------------------------------:| +| forward-auth | /api/authz/forward-auth | [ForwardAuth] | [HeaderProxyAuthorization], [CookieSession] | +| ext-authz | /api/authz/ext-authz | [ExtAuthz] | [HeaderProxyAuthorization], [CookieSession] | +| auth-request | /api/authz/auth-request | [AuthRequest] | [HeaderAuthRequestProxyAuthorization], [CookieSession] | +| legacy | /api/verify | [Legacy] | [HeaderLegacy], [CookieSession] | + +## Metadata + +Various metadata is collected from the request made to the Authelia authorization server. This table describes the +metadata collected. All of this metadata is utilized for the purpose of determining if the user is authorized to a +particular resource. + +| Name | Description | +|:------------:|:-----------------------------------------------:| +| Method | The Method Verb of the Request | +| Scheme | The URI Scheme of the Request | +| Hostname | The URI Hostname of the Request | +| Path | The URI Path of the Request | +| IP | The IP address of the client making the Request | +| Authelia URL | The URL of the Authelia Portal | + +Some values may have either fallbacks or override values. If they exist they will be in the alternatives table which +will be below the main metadata table. + +The metadata table contains the recommended source of this information and this source is often times automatic +depending on the proxy implementation. The difference between an override and a fallback is an override values will +take precedence over the metadata values, and fallbacks only take effect if the override values or metadata values are +completely unset. + +## Implementations + +### ForwardAuth + +This is the implementation which supports [Traefik] via the [ForwardAuth Middleware], [Caddy] via the +[forward_auth directive], and [Skipper] via the [webhook auth filter]. + +#### ForwardAuth Metadata + +| Metadata | Source | Key | +|:------------:|:----------------------------:|:--------------------:| +| Method | [Header] | `X-Forwarded-Method` | +| Scheme | [Header] | [X-Forwarded-Proto] | +| Hostname | [Header] | [X-Forwarded-Host] | +| Path | [Header] | `X-Forwarded-URI` | +| IP | [Header] | [X-Forwarded-For] | +| Authelia URL | Session Cookie Configuration | `authelia_url` | + +#### ForwardAuth Metadata Alternatives + +| Metadata | Alternative Type | Source | Key | +|:------------:|:----------------:|:--------------:|:--------------:| +| Scheme | Fallback | [Header] | Server Scheme | +| IP | Fallback | TCP Packet | Source IP | +| Authelia URL | Override | Query Argument | `authelia_url` | + +### ExtAuthz + +This is the implementation which supports [Envoy] via the [ExtAuthz Extension Filter]. + +#### ExtAuthz Metadata + +| Metadata | Source | Key | +|:------------:|:----------------------------:|:-------------------:| +| Method | _[Start Line]_ | [HTTP Method] | +| Scheme | [Header] | [X-Forwarded-Proto] | +| Hostname | [Header] | [Host] | +| Path | [Header] | Endpoint Sub-Path | +| IP | [Header] | [X-Forwarded-For] | +| Authelia URL | Session Cookie Configuration | `authelia_url` | + +#### ExtAuthz Metadata Alternatives + +| Metadata | Alternative Type | Source | Key | +|:------------:|:----------------:|:----------:|:------------------:| +| Scheme | Fallback | [Header] | Server Scheme | +| IP | Fallback | TCP Packet | Source IP | +| Authelia URL | Override | [Header] | `X-Authelia-URL` | + +### AuthRequest + +This is the implementation which supports [NGINX] via the [auth_request HTTP module] and [HAProxy] via the +[auth-request lua plugin]. + +| Metadata | Source | Key | +|:------------:|:--------:|:-------------------:| +| Method | [Header] | `X-Original-Method` | +| Scheme | [Header] | `X-Original-URL` | +| Hostname | [Header] | `X-Original-URL` | +| Path | [Header] | `X-Original-URL` | +| IP | [Header] | [X-Forwarded-For] | +| Authelia URL | _N/A_ | _N/A_ | + +_**Note:** This endpoint does not support automatic redirection. This is because there is no support on NGINX's side to +achieve this with `ngx_http_auth_request_module` and the redirection must be performed within the NGINX configuration._ + +#### AuthRequest Metadata Alternatives + +| Metadata | Alternative Type | Source | Key | +|:--------:|:----------------:|:----------:|:---------:| +| IP | Fallback | TCP Packet | Source IP | + +### Legacy + +This is the legacy implementation which used to operate similar to both the [ForwardAuth](#forwardauth) and +[AuthRequest](#authrequest) implementations. + +*__Note:__ This implementation has duplicate entries for metadata. This is due to the fact this implementation used to +cater for the AuthRequest and ForwardAuth implementations. The table is in order of precedence where if a header higher +in the list exists it is used over those lower in the list.* + +| Metadata | Source | Key | +|:------------:|:--------------:|:--------------------:| +| Method | [Header] | `X-Original-Method` | +| Scheme | [Header] | `X-Original-URL` | +| Hostname | [Header] | `X-Original-URL` | +| Path | [Header] | `X-Original-URL` | +| Method | [Header] | `X-Forwarded-Method` | +| Scheme | [Header] | [X-Forwarded-Proto] | +| Hostname | [Header] | [X-Forwarded-Host] | +| Path | [Header] | `X-Forwarded-URI` | +| IP | [Header] | [X-Forwarded-For] | +| Authelia URL | Query Argument | `rd` | +| Authelia URL | [Header] | `X-Authelia-URL` | + +## Authn Strategies + +Authentication strategies are used to determine the users identity which is essential to determining if they are +authorized to visit a particular resource. Authentication strategies are executed in order, and have three potential +results. + +1. Successful Authentication +2. No Authentication +3. Unsuccessful Authentication + +Result 2 is the only result in which the next strategy is attempted, this occurs when there is not enough +information in the request to perform authentication. Both result 1 and 2 result in a short-circuit, i.e. no other +strategy will be attempted. + +Result 1 occurs when the strategy requirements (i.e. a particular header) are present and the details are sufficient to +authenticate them and the details are correct. Result 2 occurs when the strategy requirements are present and either the +details are incomplete (i.e. malformed header) or the details are incorrect (i.e. bad password). + +### CookieSession + +This strategy uses a cookie which links the user to a session to determine the users identity. This is the default +strategy for end-users. + +If this strategy if included in an endpoint will redirect the user the Authelia Authorization Portal on supported +proxies when they are not authorized and can potentially be authorized provided no other strategies have critical +errors. + +### HeaderAuthorization + +This strategy uses the [Authorization] header to determine the users' identity. If the user credentials are wrong, or +the header is malformed it will respond with the [WWW-Authenticate] header and a [401 Unauthorized] status code. + +### HeaderProxyAuthorization + +This strategy uses the [Proxy-Authorization] header to determine the users' identity. If the user credentials are wrong, +or the header is malformed it will respond with the [Proxy-Authenticate] header and a +[407 Proxy Authentication Required] status code. + +### HeaderAuthRequestProxyAuthorization + +This strategy uses the [Proxy-Authorization] header to determine the users' identity. If the user credentials are wrong, +or the header is malformed it will respond with the [WWW-Authenticate] header and a [401 Unauthorized] status code. It +is specifically intended for use with the [AuthRequest] implementation. + +### HeaderLegacy + +This strategy uses the [Proxy-Authorization] header to determine the users' identity. If the user credentials are wrong, +or the header is malformed it will respond with the [WWW-Authenticate] header. + +[401 Unauthorized]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 +[407 Proxy Authentication Required]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/407 + +[NGINX]: https://www.nginx.com/ +[Traefik]: https://traefik.io/traefik/ +[Envoy]: https://www.envoyproxy.io/ +[Caddy]: https://caddyserver.com/ +[Skipper]: https://opensource.zalando.com/skipper/ +[HAProxy]: http://www.haproxy.org/ + +[ExtAuthz Extension Filter]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_authz/v3/ext_authz.proto#envoy-v3-api-msg-extensions-filters-http-ext-authz-v3-extauthz +[auth_request HTTP module]: https://nginx.org/en/docs/http/ngx_http_auth_request_module.html +[auth-request lua plugin]: https://github.com/TimWolla/haproxy-auth-request +[ForwardAuth Middleware]: https://doc.traefik.io/traefik/middlewares/http/forwardauth/ +[forward_auth directive]: https://caddyserver.com/docs/caddyfile/directives/forward_auth +[webhook auth filter]: https://opensource.zalando.com/skipper/reference/filters/#webhook + +[Implementation]: #implementations +[Authn Strategies]: #authn-strategies +[ForwardAuth]: #forwardauth +[ExtAuthz]: #extauthz +[AuthRequest]: #authrequest +[Legacy]: #legacy +[HeaderProxyAuthorization]: #headerproxyauthorization +[HeaderAuthRequestProxyAuthorization]: #headerauthrequestproxyauthorization +[HeaderLegacy]: #headerlegacy +[CookieSession]: #cookiesession + +[Authorization]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization +[WWW-Authenticate]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate +[Proxy-Authorization]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Proxy-Authorization +[Proxy-Authenticate]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Proxy-Authenticate + +[X-Forwarded-Proto]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto +[X-Forwarded-Host]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host +[X-Forwarded-For]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For +[Host]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host + +[HTTP Method]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods +[HTTP Method]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods +[Start Line]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#start_line +[Header]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers diff --git a/docs/content/en/roadmap/active/multi-domain-protection.md b/docs/content/en/roadmap/active/multi-domain-protection.md index 774272aa2..f8c1b9812 100644 --- a/docs/content/en/roadmap/active/multi-domain-protection.md +++ b/docs/content/en/roadmap/active/multi-domain-protection.md @@ -43,7 +43,7 @@ has many drawbacks that just are not satisfactory in order to easily facilitate ### Initial Implementation -{{< roadmap-status stage="waiting" >}} +{{< roadmap-status stage="in-progress" version="v4.38.0" >}} This stage is waiting on the choice to handle sessions. Initial implementation will involve just a basic cookie implementation where users will be required to sign in to each root domain and no SSO functionality will exist. diff --git a/docs/data/configkeys.json b/docs/data/configkeys.json index 1d0937984..8dcc550cb 100644 --- a/docs/data/configkeys.json +++ b/docs/data/configkeys.json @@ -1 +1 @@ -[{"path":"theme","secret":false,"env":"AUTHELIA_THEME"},{"path":"certificates_directory","secret":false,"env":"AUTHELIA_CERTIFICATES_DIRECTORY"},{"path":"jwt_secret","secret":true,"env":"AUTHELIA_JWT_SECRET_FILE"},{"path":"default_redirection_url","secret":false,"env":"AUTHELIA_DEFAULT_REDIRECTION_URL"},{"path":"default_2fa_method","secret":false,"env":"AUTHELIA_DEFAULT_2FA_METHOD"},{"path":"log.level","secret":false,"env":"AUTHELIA_LOG_LEVEL"},{"path":"log.format","secret":false,"env":"AUTHELIA_LOG_FORMAT"},{"path":"log.file_path","secret":false,"env":"AUTHELIA_LOG_FILE_PATH"},{"path":"log.keep_stdout","secret":false,"env":"AUTHELIA_LOG_KEEP_STDOUT"},{"path":"identity_providers.oidc.hmac_secret","secret":true,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE"},{"path":"identity_providers.oidc.issuer_certificate_chain","secret":true,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_CERTIFICATE_CHAIN_FILE"},{"path":"identity_providers.oidc.issuer_private_key","secret":true,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE"},{"path":"identity_providers.oidc.access_token_lifespan","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ACCESS_TOKEN_LIFESPAN"},{"path":"identity_providers.oidc.authorize_code_lifespan","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_AUTHORIZE_CODE_LIFESPAN"},{"path":"identity_providers.oidc.id_token_lifespan","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ID_TOKEN_LIFESPAN"},{"path":"identity_providers.oidc.refresh_token_lifespan","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_REFRESH_TOKEN_LIFESPAN"},{"path":"identity_providers.oidc.enable_client_debug_messages","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ENABLE_CLIENT_DEBUG_MESSAGES"},{"path":"identity_providers.oidc.minimum_parameter_entropy","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_MINIMUM_PARAMETER_ENTROPY"},{"path":"identity_providers.oidc.enforce_pkce","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ENFORCE_PKCE"},{"path":"identity_providers.oidc.enable_pkce_plain_challenge","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ENABLE_PKCE_PLAIN_CHALLENGE"},{"path":"identity_providers.oidc.cors.endpoints","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_CORS_ENDPOINTS"},{"path":"identity_providers.oidc.cors.allowed_origins","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_CORS_ALLOWED_ORIGINS"},{"path":"identity_providers.oidc.cors.allowed_origins_from_client_redirect_uris","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_CORS_ALLOWED_ORIGINS_FROM_CLIENT_REDIRECT_URIS"},{"path":"identity_providers.oidc.clients","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS"},{"path":"authentication_backend.password_reset.disable","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_PASSWORD_RESET_DISABLE"},{"path":"authentication_backend.password_reset.custom_url","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_PASSWORD_RESET_CUSTOM_URL"},{"path":"authentication_backend.refresh_interval","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_REFRESH_INTERVAL"},{"path":"authentication_backend.file.path","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PATH"},{"path":"authentication_backend.file.watch","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_WATCH"},{"path":"authentication_backend.file.password.algorithm","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ALGORITHM"},{"path":"authentication_backend.file.password.argon2.variant","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_VARIANT"},{"path":"authentication_backend.file.password.argon2.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_ITERATIONS"},{"path":"authentication_backend.file.password.argon2.memory","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_MEMORY"},{"path":"authentication_backend.file.password.argon2.parallelism","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_PARALLELISM"},{"path":"authentication_backend.file.password.argon2.key_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_KEY_LENGTH"},{"path":"authentication_backend.file.password.argon2.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_SALT_LENGTH"},{"path":"authentication_backend.file.password.sha2crypt.variant","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SHA2CRYPT_VARIANT"},{"path":"authentication_backend.file.password.sha2crypt.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SHA2CRYPT_ITERATIONS"},{"path":"authentication_backend.file.password.sha2crypt.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SHA2CRYPT_SALT_LENGTH"},{"path":"authentication_backend.file.password.pbkdf2.variant","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_PBKDF2_VARIANT"},{"path":"authentication_backend.file.password.pbkdf2.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_PBKDF2_ITERATIONS"},{"path":"authentication_backend.file.password.pbkdf2.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_PBKDF2_SALT_LENGTH"},{"path":"authentication_backend.file.password.bcrypt.variant","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_BCRYPT_VARIANT"},{"path":"authentication_backend.file.password.bcrypt.cost","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_BCRYPT_COST"},{"path":"authentication_backend.file.password.scrypt.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_ITERATIONS"},{"path":"authentication_backend.file.password.scrypt.block_size","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_BLOCK_SIZE"},{"path":"authentication_backend.file.password.scrypt.parallelism","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_PARALLELISM"},{"path":"authentication_backend.file.password.scrypt.key_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_KEY_LENGTH"},{"path":"authentication_backend.file.password.scrypt.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_SALT_LENGTH"},{"path":"authentication_backend.file.password.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ITERATIONS"},{"path":"authentication_backend.file.password.memory","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_MEMORY"},{"path":"authentication_backend.file.password.parallelism","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_PARALLELISM"},{"path":"authentication_backend.file.password.key_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_KEY_LENGTH"},{"path":"authentication_backend.file.password.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SALT_LENGTH"},{"path":"authentication_backend.file.search.email","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_SEARCH_EMAIL"},{"path":"authentication_backend.file.search.case_insensitive","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_SEARCH_CASE_INSENSITIVE"},{"path":"authentication_backend.ldap.implementation","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_IMPLEMENTATION"},{"path":"authentication_backend.ldap.url","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_URL"},{"path":"authentication_backend.ldap.timeout","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TIMEOUT"},{"path":"authentication_backend.ldap.start_tls","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_START_TLS"},{"path":"authentication_backend.ldap.tls.minimum_version","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_MINIMUM_VERSION"},{"path":"authentication_backend.ldap.tls.maximum_version","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_MAXIMUM_VERSION"},{"path":"authentication_backend.ldap.tls.skip_verify","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_SKIP_VERIFY"},{"path":"authentication_backend.ldap.tls.server_name","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_SERVER_NAME"},{"path":"authentication_backend.ldap.tls.private_key","secret":true,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_PRIVATE_KEY_FILE"},{"path":"authentication_backend.ldap.tls.certificate_chain","secret":true,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"authentication_backend.ldap.base_dn","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_BASE_DN"},{"path":"authentication_backend.ldap.additional_users_dn","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_ADDITIONAL_USERS_DN"},{"path":"authentication_backend.ldap.users_filter","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_USERS_FILTER"},{"path":"authentication_backend.ldap.additional_groups_dn","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_ADDITIONAL_GROUPS_DN"},{"path":"authentication_backend.ldap.groups_filter","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_GROUPS_FILTER"},{"path":"authentication_backend.ldap.group_name_attribute","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_GROUP_NAME_ATTRIBUTE"},{"path":"authentication_backend.ldap.username_attribute","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_USERNAME_ATTRIBUTE"},{"path":"authentication_backend.ldap.mail_attribute","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_MAIL_ATTRIBUTE"},{"path":"authentication_backend.ldap.display_name_attribute","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_DISPLAY_NAME_ATTRIBUTE"},{"path":"authentication_backend.ldap.permit_referrals","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PERMIT_REFERRALS"},{"path":"authentication_backend.ldap.permit_unauthenticated_bind","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PERMIT_UNAUTHENTICATED_BIND"},{"path":"authentication_backend.ldap.permit_feature_detection_failure","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PERMIT_FEATURE_DETECTION_FAILURE"},{"path":"authentication_backend.ldap.user","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_USER"},{"path":"authentication_backend.ldap.password","secret":true,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE"},{"path":"session.secret","secret":true,"env":"AUTHELIA_SESSION_SECRET_FILE"},{"path":"session.name","secret":false,"env":"AUTHELIA_SESSION_NAME"},{"path":"session.domain","secret":false,"env":"AUTHELIA_SESSION_DOMAIN"},{"path":"session.same_site","secret":false,"env":"AUTHELIA_SESSION_SAME_SITE"},{"path":"session.expiration","secret":false,"env":"AUTHELIA_SESSION_EXPIRATION"},{"path":"session.inactivity","secret":false,"env":"AUTHELIA_SESSION_INACTIVITY"},{"path":"session.remember_me","secret":false,"env":"AUTHELIA_SESSION_REMEMBER_ME"},{"path":"session","secret":false,"env":"AUTHELIA_SESSION"},{"path":"session.cookies","secret":false,"env":"AUTHELIA_SESSION_COOKIES"},{"path":"session.redis.host","secret":false,"env":"AUTHELIA_SESSION_REDIS_HOST"},{"path":"session.redis.port","secret":false,"env":"AUTHELIA_SESSION_REDIS_PORT"},{"path":"session.redis.username","secret":false,"env":"AUTHELIA_SESSION_REDIS_USERNAME"},{"path":"session.redis.password","secret":true,"env":"AUTHELIA_SESSION_REDIS_PASSWORD_FILE"},{"path":"session.redis.database_index","secret":false,"env":"AUTHELIA_SESSION_REDIS_DATABASE_INDEX"},{"path":"session.redis.maximum_active_connections","secret":false,"env":"AUTHELIA_SESSION_REDIS_MAXIMUM_ACTIVE_CONNECTIONS"},{"path":"session.redis.minimum_idle_connections","secret":false,"env":"AUTHELIA_SESSION_REDIS_MINIMUM_IDLE_CONNECTIONS"},{"path":"session.redis.tls.minimum_version","secret":false,"env":"AUTHELIA_SESSION_REDIS_TLS_MINIMUM_VERSION"},{"path":"session.redis.tls.maximum_version","secret":false,"env":"AUTHELIA_SESSION_REDIS_TLS_MAXIMUM_VERSION"},{"path":"session.redis.tls.skip_verify","secret":false,"env":"AUTHELIA_SESSION_REDIS_TLS_SKIP_VERIFY"},{"path":"session.redis.tls.server_name","secret":false,"env":"AUTHELIA_SESSION_REDIS_TLS_SERVER_NAME"},{"path":"session.redis.tls.private_key","secret":true,"env":"AUTHELIA_SESSION_REDIS_TLS_PRIVATE_KEY_FILE"},{"path":"session.redis.tls.certificate_chain","secret":true,"env":"AUTHELIA_SESSION_REDIS_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"session.redis.high_availability.sentinel_name","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_NAME"},{"path":"session.redis.high_availability.sentinel_username","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_USERNAME"},{"path":"session.redis.high_availability.sentinel_password","secret":true,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE"},{"path":"session.redis.high_availability.nodes","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_NODES"},{"path":"session.redis.high_availability.route_by_latency","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_ROUTE_BY_LATENCY"},{"path":"session.redis.high_availability.route_randomly","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_ROUTE_RANDOMLY"},{"path":"totp.disable","secret":false,"env":"AUTHELIA_TOTP_DISABLE"},{"path":"totp.issuer","secret":false,"env":"AUTHELIA_TOTP_ISSUER"},{"path":"totp.algorithm","secret":false,"env":"AUTHELIA_TOTP_ALGORITHM"},{"path":"totp.digits","secret":false,"env":"AUTHELIA_TOTP_DIGITS"},{"path":"totp.period","secret":false,"env":"AUTHELIA_TOTP_PERIOD"},{"path":"totp.skew","secret":false,"env":"AUTHELIA_TOTP_SKEW"},{"path":"totp.secret_size","secret":false,"env":"AUTHELIA_TOTP_SECRET_SIZE"},{"path":"duo_api.disable","secret":false,"env":"AUTHELIA_DUO_API_DISABLE"},{"path":"duo_api.hostname","secret":false,"env":"AUTHELIA_DUO_API_HOSTNAME"},{"path":"duo_api.integration_key","secret":true,"env":"AUTHELIA_DUO_API_INTEGRATION_KEY_FILE"},{"path":"duo_api.secret_key","secret":true,"env":"AUTHELIA_DUO_API_SECRET_KEY_FILE"},{"path":"duo_api.enable_self_enrollment","secret":false,"env":"AUTHELIA_DUO_API_ENABLE_SELF_ENROLLMENT"},{"path":"access_control.default_policy","secret":false,"env":"AUTHELIA_ACCESS_CONTROL_DEFAULT_POLICY"},{"path":"access_control.networks","secret":false,"env":"AUTHELIA_ACCESS_CONTROL_NETWORKS"},{"path":"access_control.rules","secret":false,"env":"AUTHELIA_ACCESS_CONTROL_RULES"},{"path":"ntp.address","secret":false,"env":"AUTHELIA_NTP_ADDRESS"},{"path":"ntp.version","secret":false,"env":"AUTHELIA_NTP_VERSION"},{"path":"ntp.max_desync","secret":false,"env":"AUTHELIA_NTP_MAX_DESYNC"},{"path":"ntp.disable_startup_check","secret":false,"env":"AUTHELIA_NTP_DISABLE_STARTUP_CHECK"},{"path":"ntp.disable_failure","secret":false,"env":"AUTHELIA_NTP_DISABLE_FAILURE"},{"path":"regulation.max_retries","secret":false,"env":"AUTHELIA_REGULATION_MAX_RETRIES"},{"path":"regulation.find_time","secret":false,"env":"AUTHELIA_REGULATION_FIND_TIME"},{"path":"regulation.ban_time","secret":false,"env":"AUTHELIA_REGULATION_BAN_TIME"},{"path":"storage.local.path","secret":false,"env":"AUTHELIA_STORAGE_LOCAL_PATH"},{"path":"storage.mysql.host","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_HOST"},{"path":"storage.mysql.port","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_PORT"},{"path":"storage.mysql.database","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_DATABASE"},{"path":"storage.mysql.username","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_USERNAME"},{"path":"storage.mysql.password","secret":true,"env":"AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE"},{"path":"storage.mysql.timeout","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TIMEOUT"},{"path":"storage.mysql.tls.minimum_version","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TLS_MINIMUM_VERSION"},{"path":"storage.mysql.tls.maximum_version","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TLS_MAXIMUM_VERSION"},{"path":"storage.mysql.tls.skip_verify","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TLS_SKIP_VERIFY"},{"path":"storage.mysql.tls.server_name","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TLS_SERVER_NAME"},{"path":"storage.mysql.tls.private_key","secret":true,"env":"AUTHELIA_STORAGE_MYSQL_TLS_PRIVATE_KEY_FILE"},{"path":"storage.mysql.tls.certificate_chain","secret":true,"env":"AUTHELIA_STORAGE_MYSQL_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"storage.postgres.host","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_HOST"},{"path":"storage.postgres.port","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_PORT"},{"path":"storage.postgres.database","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_DATABASE"},{"path":"storage.postgres.username","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_USERNAME"},{"path":"storage.postgres.password","secret":true,"env":"AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE"},{"path":"storage.postgres.timeout","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TIMEOUT"},{"path":"storage.postgres.schema","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_SCHEMA"},{"path":"storage.postgres.tls.minimum_version","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_MINIMUM_VERSION"},{"path":"storage.postgres.tls.maximum_version","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_MAXIMUM_VERSION"},{"path":"storage.postgres.tls.skip_verify","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_SKIP_VERIFY"},{"path":"storage.postgres.tls.server_name","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_SERVER_NAME"},{"path":"storage.postgres.tls.private_key","secret":true,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_PRIVATE_KEY_FILE"},{"path":"storage.postgres.tls.certificate_chain","secret":true,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"storage.postgres.ssl.mode","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_SSL_MODE"},{"path":"storage.postgres.ssl.root_certificate","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_SSL_ROOT_CERTIFICATE"},{"path":"storage.postgres.ssl.certificate","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_SSL_CERTIFICATE"},{"path":"storage.postgres.ssl.key","secret":true,"env":"AUTHELIA_STORAGE_POSTGRES_SSL_KEY_FILE"},{"path":"storage.encryption_key","secret":true,"env":"AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE"},{"path":"notifier.disable_startup_check","secret":false,"env":"AUTHELIA_NOTIFIER_DISABLE_STARTUP_CHECK"},{"path":"notifier.filesystem.filename","secret":false,"env":"AUTHELIA_NOTIFIER_FILESYSTEM_FILENAME"},{"path":"notifier.smtp.host","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_HOST"},{"path":"notifier.smtp.port","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_PORT"},{"path":"notifier.smtp.timeout","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TIMEOUT"},{"path":"notifier.smtp.username","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_USERNAME"},{"path":"notifier.smtp.password","secret":true,"env":"AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE"},{"path":"notifier.smtp.identifier","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_IDENTIFIER"},{"path":"notifier.smtp.sender","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_SENDER"},{"path":"notifier.smtp.subject","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_SUBJECT"},{"path":"notifier.smtp.startup_check_address","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_STARTUP_CHECK_ADDRESS"},{"path":"notifier.smtp.disable_require_tls","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_DISABLE_REQUIRE_TLS"},{"path":"notifier.smtp.disable_html_emails","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_DISABLE_HTML_EMAILS"},{"path":"notifier.smtp.disable_starttls","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_DISABLE_STARTTLS"},{"path":"notifier.smtp.tls.minimum_version","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_MINIMUM_VERSION"},{"path":"notifier.smtp.tls.maximum_version","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_MAXIMUM_VERSION"},{"path":"notifier.smtp.tls.skip_verify","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_SKIP_VERIFY"},{"path":"notifier.smtp.tls.server_name","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_SERVER_NAME"},{"path":"notifier.smtp.tls.private_key","secret":true,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_PRIVATE_KEY_FILE"},{"path":"notifier.smtp.tls.certificate_chain","secret":true,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"notifier.template_path","secret":false,"env":"AUTHELIA_NOTIFIER_TEMPLATE_PATH"},{"path":"server.host","secret":false,"env":"AUTHELIA_SERVER_HOST"},{"path":"server.port","secret":false,"env":"AUTHELIA_SERVER_PORT"},{"path":"server.path","secret":false,"env":"AUTHELIA_SERVER_PATH"},{"path":"server.asset_path","secret":false,"env":"AUTHELIA_SERVER_ASSET_PATH"},{"path":"server.enable_pprof","secret":false,"env":"AUTHELIA_SERVER_ENABLE_PPROF"},{"path":"server.enable_expvars","secret":false,"env":"AUTHELIA_SERVER_ENABLE_EXPVARS"},{"path":"server.disable_healthcheck","secret":false,"env":"AUTHELIA_SERVER_DISABLE_HEALTHCHECK"},{"path":"server.tls.certificate","secret":false,"env":"AUTHELIA_SERVER_TLS_CERTIFICATE"},{"path":"server.tls.key","secret":true,"env":"AUTHELIA_SERVER_TLS_KEY_FILE"},{"path":"server.tls.client_certificates","secret":false,"env":"AUTHELIA_SERVER_TLS_CLIENT_CERTIFICATES"},{"path":"server.headers.csp_template","secret":false,"env":"AUTHELIA_SERVER_HEADERS_CSP_TEMPLATE"},{"path":"server.buffers.read","secret":false,"env":"AUTHELIA_SERVER_BUFFERS_READ"},{"path":"server.buffers.write","secret":false,"env":"AUTHELIA_SERVER_BUFFERS_WRITE"},{"path":"server.timeouts.read","secret":false,"env":"AUTHELIA_SERVER_TIMEOUTS_READ"},{"path":"server.timeouts.write","secret":false,"env":"AUTHELIA_SERVER_TIMEOUTS_WRITE"},{"path":"server.timeouts.idle","secret":false,"env":"AUTHELIA_SERVER_TIMEOUTS_IDLE"},{"path":"telemetry.metrics.enabled","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_ENABLED"},{"path":"telemetry.metrics.address","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_ADDRESS"},{"path":"telemetry.metrics.buffers.read","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_BUFFERS_READ"},{"path":"telemetry.metrics.buffers.write","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_BUFFERS_WRITE"},{"path":"telemetry.metrics.timeouts.read","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_TIMEOUTS_READ"},{"path":"telemetry.metrics.timeouts.write","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_TIMEOUTS_WRITE"},{"path":"telemetry.metrics.timeouts.idle","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_TIMEOUTS_IDLE"},{"path":"webauthn.disable","secret":false,"env":"AUTHELIA_WEBAUTHN_DISABLE"},{"path":"webauthn.display_name","secret":false,"env":"AUTHELIA_WEBAUTHN_DISPLAY_NAME"},{"path":"webauthn.attestation_conveyance_preference","secret":false,"env":"AUTHELIA_WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE"},{"path":"webauthn.user_verification","secret":false,"env":"AUTHELIA_WEBAUTHN_USER_VERIFICATION"},{"path":"webauthn.timeout","secret":false,"env":"AUTHELIA_WEBAUTHN_TIMEOUT"},{"path":"password_policy.standard.enabled","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_ENABLED"},{"path":"password_policy.standard.min_length","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_MIN_LENGTH"},{"path":"password_policy.standard.max_length","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_MAX_LENGTH"},{"path":"password_policy.standard.require_uppercase","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_REQUIRE_UPPERCASE"},{"path":"password_policy.standard.require_lowercase","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_REQUIRE_LOWERCASE"},{"path":"password_policy.standard.require_number","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_REQUIRE_NUMBER"},{"path":"password_policy.standard.require_special","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_REQUIRE_SPECIAL"},{"path":"password_policy.zxcvbn.enabled","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_ZXCVBN_ENABLED"},{"path":"password_policy.zxcvbn.min_score","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_ZXCVBN_MIN_SCORE"}] \ No newline at end of file +[{"path":"theme","secret":false,"env":"AUTHELIA_THEME"},{"path":"certificates_directory","secret":false,"env":"AUTHELIA_CERTIFICATES_DIRECTORY"},{"path":"jwt_secret","secret":true,"env":"AUTHELIA_JWT_SECRET_FILE"},{"path":"default_redirection_url","secret":false,"env":"AUTHELIA_DEFAULT_REDIRECTION_URL"},{"path":"default_2fa_method","secret":false,"env":"AUTHELIA_DEFAULT_2FA_METHOD"},{"path":"log.level","secret":false,"env":"AUTHELIA_LOG_LEVEL"},{"path":"log.format","secret":false,"env":"AUTHELIA_LOG_FORMAT"},{"path":"log.file_path","secret":false,"env":"AUTHELIA_LOG_FILE_PATH"},{"path":"log.keep_stdout","secret":false,"env":"AUTHELIA_LOG_KEEP_STDOUT"},{"path":"identity_providers.oidc.hmac_secret","secret":true,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE"},{"path":"identity_providers.oidc.issuer_certificate_chain","secret":true,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_CERTIFICATE_CHAIN_FILE"},{"path":"identity_providers.oidc.issuer_private_key","secret":true,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE"},{"path":"identity_providers.oidc.access_token_lifespan","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ACCESS_TOKEN_LIFESPAN"},{"path":"identity_providers.oidc.authorize_code_lifespan","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_AUTHORIZE_CODE_LIFESPAN"},{"path":"identity_providers.oidc.id_token_lifespan","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ID_TOKEN_LIFESPAN"},{"path":"identity_providers.oidc.refresh_token_lifespan","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_REFRESH_TOKEN_LIFESPAN"},{"path":"identity_providers.oidc.enable_client_debug_messages","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ENABLE_CLIENT_DEBUG_MESSAGES"},{"path":"identity_providers.oidc.minimum_parameter_entropy","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_MINIMUM_PARAMETER_ENTROPY"},{"path":"identity_providers.oidc.enforce_pkce","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ENFORCE_PKCE"},{"path":"identity_providers.oidc.enable_pkce_plain_challenge","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_ENABLE_PKCE_PLAIN_CHALLENGE"},{"path":"identity_providers.oidc.cors.endpoints","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_CORS_ENDPOINTS"},{"path":"identity_providers.oidc.cors.allowed_origins","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_CORS_ALLOWED_ORIGINS"},{"path":"identity_providers.oidc.cors.allowed_origins_from_client_redirect_uris","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_CORS_ALLOWED_ORIGINS_FROM_CLIENT_REDIRECT_URIS"},{"path":"identity_providers.oidc.clients","secret":false,"env":"AUTHELIA_IDENTITY_PROVIDERS_OIDC_CLIENTS"},{"path":"authentication_backend.password_reset.disable","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_PASSWORD_RESET_DISABLE"},{"path":"authentication_backend.password_reset.custom_url","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_PASSWORD_RESET_CUSTOM_URL"},{"path":"authentication_backend.refresh_interval","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_REFRESH_INTERVAL"},{"path":"authentication_backend.file.path","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PATH"},{"path":"authentication_backend.file.watch","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_WATCH"},{"path":"authentication_backend.file.password.algorithm","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ALGORITHM"},{"path":"authentication_backend.file.password.argon2.variant","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_VARIANT"},{"path":"authentication_backend.file.password.argon2.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_ITERATIONS"},{"path":"authentication_backend.file.password.argon2.memory","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_MEMORY"},{"path":"authentication_backend.file.password.argon2.parallelism","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_PARALLELISM"},{"path":"authentication_backend.file.password.argon2.key_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_KEY_LENGTH"},{"path":"authentication_backend.file.password.argon2.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ARGON2_SALT_LENGTH"},{"path":"authentication_backend.file.password.sha2crypt.variant","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SHA2CRYPT_VARIANT"},{"path":"authentication_backend.file.password.sha2crypt.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SHA2CRYPT_ITERATIONS"},{"path":"authentication_backend.file.password.sha2crypt.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SHA2CRYPT_SALT_LENGTH"},{"path":"authentication_backend.file.password.pbkdf2.variant","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_PBKDF2_VARIANT"},{"path":"authentication_backend.file.password.pbkdf2.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_PBKDF2_ITERATIONS"},{"path":"authentication_backend.file.password.pbkdf2.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_PBKDF2_SALT_LENGTH"},{"path":"authentication_backend.file.password.bcrypt.variant","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_BCRYPT_VARIANT"},{"path":"authentication_backend.file.password.bcrypt.cost","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_BCRYPT_COST"},{"path":"authentication_backend.file.password.scrypt.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_ITERATIONS"},{"path":"authentication_backend.file.password.scrypt.block_size","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_BLOCK_SIZE"},{"path":"authentication_backend.file.password.scrypt.parallelism","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_PARALLELISM"},{"path":"authentication_backend.file.password.scrypt.key_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_KEY_LENGTH"},{"path":"authentication_backend.file.password.scrypt.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SCRYPT_SALT_LENGTH"},{"path":"authentication_backend.file.password.iterations","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_ITERATIONS"},{"path":"authentication_backend.file.password.memory","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_MEMORY"},{"path":"authentication_backend.file.password.parallelism","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_PARALLELISM"},{"path":"authentication_backend.file.password.key_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_KEY_LENGTH"},{"path":"authentication_backend.file.password.salt_length","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_PASSWORD_SALT_LENGTH"},{"path":"authentication_backend.file.search.email","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_SEARCH_EMAIL"},{"path":"authentication_backend.file.search.case_insensitive","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_FILE_SEARCH_CASE_INSENSITIVE"},{"path":"authentication_backend.ldap.implementation","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_IMPLEMENTATION"},{"path":"authentication_backend.ldap.url","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_URL"},{"path":"authentication_backend.ldap.timeout","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TIMEOUT"},{"path":"authentication_backend.ldap.start_tls","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_START_TLS"},{"path":"authentication_backend.ldap.tls.minimum_version","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_MINIMUM_VERSION"},{"path":"authentication_backend.ldap.tls.maximum_version","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_MAXIMUM_VERSION"},{"path":"authentication_backend.ldap.tls.skip_verify","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_SKIP_VERIFY"},{"path":"authentication_backend.ldap.tls.server_name","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_SERVER_NAME"},{"path":"authentication_backend.ldap.tls.private_key","secret":true,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_PRIVATE_KEY_FILE"},{"path":"authentication_backend.ldap.tls.certificate_chain","secret":true,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"authentication_backend.ldap.base_dn","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_BASE_DN"},{"path":"authentication_backend.ldap.additional_users_dn","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_ADDITIONAL_USERS_DN"},{"path":"authentication_backend.ldap.users_filter","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_USERS_FILTER"},{"path":"authentication_backend.ldap.additional_groups_dn","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_ADDITIONAL_GROUPS_DN"},{"path":"authentication_backend.ldap.groups_filter","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_GROUPS_FILTER"},{"path":"authentication_backend.ldap.group_name_attribute","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_GROUP_NAME_ATTRIBUTE"},{"path":"authentication_backend.ldap.username_attribute","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_USERNAME_ATTRIBUTE"},{"path":"authentication_backend.ldap.mail_attribute","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_MAIL_ATTRIBUTE"},{"path":"authentication_backend.ldap.display_name_attribute","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_DISPLAY_NAME_ATTRIBUTE"},{"path":"authentication_backend.ldap.permit_referrals","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PERMIT_REFERRALS"},{"path":"authentication_backend.ldap.permit_unauthenticated_bind","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PERMIT_UNAUTHENTICATED_BIND"},{"path":"authentication_backend.ldap.permit_feature_detection_failure","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PERMIT_FEATURE_DETECTION_FAILURE"},{"path":"authentication_backend.ldap.user","secret":false,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_USER"},{"path":"authentication_backend.ldap.password","secret":true,"env":"AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE"},{"path":"session.secret","secret":true,"env":"AUTHELIA_SESSION_SECRET_FILE"},{"path":"session.name","secret":false,"env":"AUTHELIA_SESSION_NAME"},{"path":"session.domain","secret":false,"env":"AUTHELIA_SESSION_DOMAIN"},{"path":"session.same_site","secret":false,"env":"AUTHELIA_SESSION_SAME_SITE"},{"path":"session.expiration","secret":false,"env":"AUTHELIA_SESSION_EXPIRATION"},{"path":"session.inactivity","secret":false,"env":"AUTHELIA_SESSION_INACTIVITY"},{"path":"session.remember_me","secret":false,"env":"AUTHELIA_SESSION_REMEMBER_ME"},{"path":"session","secret":false,"env":"AUTHELIA_SESSION"},{"path":"session.cookies","secret":false,"env":"AUTHELIA_SESSION_COOKIES"},{"path":"session.redis.host","secret":false,"env":"AUTHELIA_SESSION_REDIS_HOST"},{"path":"session.redis.port","secret":false,"env":"AUTHELIA_SESSION_REDIS_PORT"},{"path":"session.redis.username","secret":false,"env":"AUTHELIA_SESSION_REDIS_USERNAME"},{"path":"session.redis.password","secret":true,"env":"AUTHELIA_SESSION_REDIS_PASSWORD_FILE"},{"path":"session.redis.database_index","secret":false,"env":"AUTHELIA_SESSION_REDIS_DATABASE_INDEX"},{"path":"session.redis.maximum_active_connections","secret":false,"env":"AUTHELIA_SESSION_REDIS_MAXIMUM_ACTIVE_CONNECTIONS"},{"path":"session.redis.minimum_idle_connections","secret":false,"env":"AUTHELIA_SESSION_REDIS_MINIMUM_IDLE_CONNECTIONS"},{"path":"session.redis.tls.minimum_version","secret":false,"env":"AUTHELIA_SESSION_REDIS_TLS_MINIMUM_VERSION"},{"path":"session.redis.tls.maximum_version","secret":false,"env":"AUTHELIA_SESSION_REDIS_TLS_MAXIMUM_VERSION"},{"path":"session.redis.tls.skip_verify","secret":false,"env":"AUTHELIA_SESSION_REDIS_TLS_SKIP_VERIFY"},{"path":"session.redis.tls.server_name","secret":false,"env":"AUTHELIA_SESSION_REDIS_TLS_SERVER_NAME"},{"path":"session.redis.tls.private_key","secret":true,"env":"AUTHELIA_SESSION_REDIS_TLS_PRIVATE_KEY_FILE"},{"path":"session.redis.tls.certificate_chain","secret":true,"env":"AUTHELIA_SESSION_REDIS_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"session.redis.high_availability.sentinel_name","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_NAME"},{"path":"session.redis.high_availability.sentinel_username","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_USERNAME"},{"path":"session.redis.high_availability.sentinel_password","secret":true,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_SENTINEL_PASSWORD_FILE"},{"path":"session.redis.high_availability.nodes","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_NODES"},{"path":"session.redis.high_availability.route_by_latency","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_ROUTE_BY_LATENCY"},{"path":"session.redis.high_availability.route_randomly","secret":false,"env":"AUTHELIA_SESSION_REDIS_HIGH_AVAILABILITY_ROUTE_RANDOMLY"},{"path":"totp.disable","secret":false,"env":"AUTHELIA_TOTP_DISABLE"},{"path":"totp.issuer","secret":false,"env":"AUTHELIA_TOTP_ISSUER"},{"path":"totp.algorithm","secret":false,"env":"AUTHELIA_TOTP_ALGORITHM"},{"path":"totp.digits","secret":false,"env":"AUTHELIA_TOTP_DIGITS"},{"path":"totp.period","secret":false,"env":"AUTHELIA_TOTP_PERIOD"},{"path":"totp.skew","secret":false,"env":"AUTHELIA_TOTP_SKEW"},{"path":"totp.secret_size","secret":false,"env":"AUTHELIA_TOTP_SECRET_SIZE"},{"path":"duo_api.disable","secret":false,"env":"AUTHELIA_DUO_API_DISABLE"},{"path":"duo_api.hostname","secret":false,"env":"AUTHELIA_DUO_API_HOSTNAME"},{"path":"duo_api.integration_key","secret":true,"env":"AUTHELIA_DUO_API_INTEGRATION_KEY_FILE"},{"path":"duo_api.secret_key","secret":true,"env":"AUTHELIA_DUO_API_SECRET_KEY_FILE"},{"path":"duo_api.enable_self_enrollment","secret":false,"env":"AUTHELIA_DUO_API_ENABLE_SELF_ENROLLMENT"},{"path":"access_control.default_policy","secret":false,"env":"AUTHELIA_ACCESS_CONTROL_DEFAULT_POLICY"},{"path":"access_control.networks","secret":false,"env":"AUTHELIA_ACCESS_CONTROL_NETWORKS"},{"path":"access_control.rules","secret":false,"env":"AUTHELIA_ACCESS_CONTROL_RULES"},{"path":"ntp.address","secret":false,"env":"AUTHELIA_NTP_ADDRESS"},{"path":"ntp.version","secret":false,"env":"AUTHELIA_NTP_VERSION"},{"path":"ntp.max_desync","secret":false,"env":"AUTHELIA_NTP_MAX_DESYNC"},{"path":"ntp.disable_startup_check","secret":false,"env":"AUTHELIA_NTP_DISABLE_STARTUP_CHECK"},{"path":"ntp.disable_failure","secret":false,"env":"AUTHELIA_NTP_DISABLE_FAILURE"},{"path":"regulation.max_retries","secret":false,"env":"AUTHELIA_REGULATION_MAX_RETRIES"},{"path":"regulation.find_time","secret":false,"env":"AUTHELIA_REGULATION_FIND_TIME"},{"path":"regulation.ban_time","secret":false,"env":"AUTHELIA_REGULATION_BAN_TIME"},{"path":"storage.local.path","secret":false,"env":"AUTHELIA_STORAGE_LOCAL_PATH"},{"path":"storage.mysql.host","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_HOST"},{"path":"storage.mysql.port","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_PORT"},{"path":"storage.mysql.database","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_DATABASE"},{"path":"storage.mysql.username","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_USERNAME"},{"path":"storage.mysql.password","secret":true,"env":"AUTHELIA_STORAGE_MYSQL_PASSWORD_FILE"},{"path":"storage.mysql.timeout","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TIMEOUT"},{"path":"storage.mysql.tls.minimum_version","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TLS_MINIMUM_VERSION"},{"path":"storage.mysql.tls.maximum_version","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TLS_MAXIMUM_VERSION"},{"path":"storage.mysql.tls.skip_verify","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TLS_SKIP_VERIFY"},{"path":"storage.mysql.tls.server_name","secret":false,"env":"AUTHELIA_STORAGE_MYSQL_TLS_SERVER_NAME"},{"path":"storage.mysql.tls.private_key","secret":true,"env":"AUTHELIA_STORAGE_MYSQL_TLS_PRIVATE_KEY_FILE"},{"path":"storage.mysql.tls.certificate_chain","secret":true,"env":"AUTHELIA_STORAGE_MYSQL_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"storage.postgres.host","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_HOST"},{"path":"storage.postgres.port","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_PORT"},{"path":"storage.postgres.database","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_DATABASE"},{"path":"storage.postgres.username","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_USERNAME"},{"path":"storage.postgres.password","secret":true,"env":"AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE"},{"path":"storage.postgres.timeout","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TIMEOUT"},{"path":"storage.postgres.schema","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_SCHEMA"},{"path":"storage.postgres.tls.minimum_version","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_MINIMUM_VERSION"},{"path":"storage.postgres.tls.maximum_version","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_MAXIMUM_VERSION"},{"path":"storage.postgres.tls.skip_verify","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_SKIP_VERIFY"},{"path":"storage.postgres.tls.server_name","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_SERVER_NAME"},{"path":"storage.postgres.tls.private_key","secret":true,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_PRIVATE_KEY_FILE"},{"path":"storage.postgres.tls.certificate_chain","secret":true,"env":"AUTHELIA_STORAGE_POSTGRES_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"storage.postgres.ssl.mode","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_SSL_MODE"},{"path":"storage.postgres.ssl.root_certificate","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_SSL_ROOT_CERTIFICATE"},{"path":"storage.postgres.ssl.certificate","secret":false,"env":"AUTHELIA_STORAGE_POSTGRES_SSL_CERTIFICATE"},{"path":"storage.postgres.ssl.key","secret":true,"env":"AUTHELIA_STORAGE_POSTGRES_SSL_KEY_FILE"},{"path":"storage.encryption_key","secret":true,"env":"AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE"},{"path":"notifier.disable_startup_check","secret":false,"env":"AUTHELIA_NOTIFIER_DISABLE_STARTUP_CHECK"},{"path":"notifier.filesystem.filename","secret":false,"env":"AUTHELIA_NOTIFIER_FILESYSTEM_FILENAME"},{"path":"notifier.smtp.host","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_HOST"},{"path":"notifier.smtp.port","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_PORT"},{"path":"notifier.smtp.timeout","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TIMEOUT"},{"path":"notifier.smtp.username","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_USERNAME"},{"path":"notifier.smtp.password","secret":true,"env":"AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE"},{"path":"notifier.smtp.identifier","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_IDENTIFIER"},{"path":"notifier.smtp.sender","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_SENDER"},{"path":"notifier.smtp.subject","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_SUBJECT"},{"path":"notifier.smtp.startup_check_address","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_STARTUP_CHECK_ADDRESS"},{"path":"notifier.smtp.disable_require_tls","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_DISABLE_REQUIRE_TLS"},{"path":"notifier.smtp.disable_html_emails","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_DISABLE_HTML_EMAILS"},{"path":"notifier.smtp.disable_starttls","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_DISABLE_STARTTLS"},{"path":"notifier.smtp.tls.minimum_version","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_MINIMUM_VERSION"},{"path":"notifier.smtp.tls.maximum_version","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_MAXIMUM_VERSION"},{"path":"notifier.smtp.tls.skip_verify","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_SKIP_VERIFY"},{"path":"notifier.smtp.tls.server_name","secret":false,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_SERVER_NAME"},{"path":"notifier.smtp.tls.private_key","secret":true,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_PRIVATE_KEY_FILE"},{"path":"notifier.smtp.tls.certificate_chain","secret":true,"env":"AUTHELIA_NOTIFIER_SMTP_TLS_CERTIFICATE_CHAIN_FILE"},{"path":"notifier.template_path","secret":false,"env":"AUTHELIA_NOTIFIER_TEMPLATE_PATH"},{"path":"server.host","secret":false,"env":"AUTHELIA_SERVER_HOST"},{"path":"server.port","secret":false,"env":"AUTHELIA_SERVER_PORT"},{"path":"server.path","secret":false,"env":"AUTHELIA_SERVER_PATH"},{"path":"server.asset_path","secret":false,"env":"AUTHELIA_SERVER_ASSET_PATH"},{"path":"server.disable_healthcheck","secret":false,"env":"AUTHELIA_SERVER_DISABLE_HEALTHCHECK"},{"path":"server.tls.certificate","secret":false,"env":"AUTHELIA_SERVER_TLS_CERTIFICATE"},{"path":"server.tls.key","secret":true,"env":"AUTHELIA_SERVER_TLS_KEY_FILE"},{"path":"server.tls.client_certificates","secret":false,"env":"AUTHELIA_SERVER_TLS_CLIENT_CERTIFICATES"},{"path":"server.headers.csp_template","secret":false,"env":"AUTHELIA_SERVER_HEADERS_CSP_TEMPLATE"},{"path":"server.endpoints.enable_pprof","secret":false,"env":"AUTHELIA_SERVER_ENDPOINTS_ENABLE_PPROF"},{"path":"server.endpoints.enable_expvars","secret":false,"env":"AUTHELIA_SERVER_ENDPOINTS_ENABLE_EXPVARS"},{"path":"server.endpoints.authz","secret":false,"env":"AUTHELIA_SERVER_ENDPOINTS_AUTHZ"},{"path":"server.endpoints.authz.*.implementation","secret":false,"env":"AUTHELIA_SERVER_ENDPOINTS_AUTHZ_*_IMPLEMENTATION"},{"path":"server.endpoints.authz.*.authn_strategies","secret":false,"env":"AUTHELIA_SERVER_ENDPOINTS_AUTHZ_*_AUTHN_STRATEGIES"},{"path":"server.buffers.read","secret":false,"env":"AUTHELIA_SERVER_BUFFERS_READ"},{"path":"server.buffers.write","secret":false,"env":"AUTHELIA_SERVER_BUFFERS_WRITE"},{"path":"server.timeouts.read","secret":false,"env":"AUTHELIA_SERVER_TIMEOUTS_READ"},{"path":"server.timeouts.write","secret":false,"env":"AUTHELIA_SERVER_TIMEOUTS_WRITE"},{"path":"server.timeouts.idle","secret":false,"env":"AUTHELIA_SERVER_TIMEOUTS_IDLE"},{"path":"telemetry.metrics.enabled","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_ENABLED"},{"path":"telemetry.metrics.address","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_ADDRESS"},{"path":"telemetry.metrics.buffers.read","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_BUFFERS_READ"},{"path":"telemetry.metrics.buffers.write","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_BUFFERS_WRITE"},{"path":"telemetry.metrics.timeouts.read","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_TIMEOUTS_READ"},{"path":"telemetry.metrics.timeouts.write","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_TIMEOUTS_WRITE"},{"path":"telemetry.metrics.timeouts.idle","secret":false,"env":"AUTHELIA_TELEMETRY_METRICS_TIMEOUTS_IDLE"},{"path":"webauthn.disable","secret":false,"env":"AUTHELIA_WEBAUTHN_DISABLE"},{"path":"webauthn.display_name","secret":false,"env":"AUTHELIA_WEBAUTHN_DISPLAY_NAME"},{"path":"webauthn.attestation_conveyance_preference","secret":false,"env":"AUTHELIA_WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE"},{"path":"webauthn.user_verification","secret":false,"env":"AUTHELIA_WEBAUTHN_USER_VERIFICATION"},{"path":"webauthn.timeout","secret":false,"env":"AUTHELIA_WEBAUTHN_TIMEOUT"},{"path":"password_policy.standard.enabled","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_ENABLED"},{"path":"password_policy.standard.min_length","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_MIN_LENGTH"},{"path":"password_policy.standard.max_length","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_MAX_LENGTH"},{"path":"password_policy.standard.require_uppercase","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_REQUIRE_UPPERCASE"},{"path":"password_policy.standard.require_lowercase","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_REQUIRE_LOWERCASE"},{"path":"password_policy.standard.require_number","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_REQUIRE_NUMBER"},{"path":"password_policy.standard.require_special","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_STANDARD_REQUIRE_SPECIAL"},{"path":"password_policy.zxcvbn.enabled","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_ZXCVBN_ENABLED"},{"path":"password_policy.zxcvbn.min_score","secret":false,"env":"AUTHELIA_PASSWORD_POLICY_ZXCVBN_MIN_SCORE"},{"path":"privacy_policy.enabled","secret":false,"env":"AUTHELIA_PRIVACY_POLICY_ENABLED"},{"path":"privacy_policy.require_user_acceptance","secret":false,"env":"AUTHELIA_PRIVACY_POLICY_REQUIRE_USER_ACCEPTANCE"},{"path":"privacy_policy.policy_url","secret":false,"env":"AUTHELIA_PRIVACY_POLICY_POLICY_URL"}] \ No newline at end of file diff --git a/examples/compose/lite/docker-compose.yml b/examples/compose/lite/docker-compose.yml index 5d0cc450d..4ab9849b8 100644 --- a/examples/compose/lite/docker-compose.yml +++ b/examples/compose/lite/docker-compose.yml @@ -19,7 +19,7 @@ services: - 'traefik.http.routers.authelia.entrypoints=https' - 'traefik.http.routers.authelia.tls=true' - 'traefik.http.routers.authelia.tls.certresolver=letsencrypt' - - 'traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/verify?rd=https://authelia.example.com' # yamllint disable-line rule:line-length + - 'traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/authz/forward-auth?authelia_url=https://authelia.example.com' # yamllint disable-line rule:line-length - 'traefik.http.middlewares.authelia.forwardauth.trustForwardHeader=true' - 'traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' # yamllint disable-line rule:line-length expose: @@ -61,8 +61,8 @@ services: - 'traefik.http.routers.api.tls.certresolver=letsencrypt' - 'traefik.http.routers.api.middlewares=authelia@docker' ports: - - 80:80 - - 443:443 + - '80:80' + - '443:443' command: - '--api' - '--providers.docker=true' diff --git a/examples/compose/local/docker-compose.yml b/examples/compose/local/docker-compose.yml index 59619d41e..8d3015f52 100644 --- a/examples/compose/local/docker-compose.yml +++ b/examples/compose/local/docker-compose.yml @@ -19,7 +19,7 @@ services: - 'traefik.http.routers.authelia.entrypoints=https' - 'traefik.http.routers.authelia.tls=true' - 'traefik.http.routers.authelia.tls.options=default' - - 'traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/verify?rd=https://authelia.example.com' # yamllint disable-line rule:line-length + - 'traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/authz/forward-auth?authelia-url=https://authelia.example.com' # yamllint disable-line rule:line-length - 'traefik.http.middlewares.authelia.forwardauth.trustForwardHeader=true' - 'traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' # yamllint disable-line rule:line-length expose: @@ -48,8 +48,8 @@ services: - 'traefik.http.routers.api.tls.options=default' - 'traefik.http.routers.api.middlewares=authelia@docker' ports: - - 80:80 - - 443:443 + - '80:80' + - '443:443' command: - '--api' - '--providers.docker=true' diff --git a/internal/authentication/const.go b/internal/authentication/const.go index 4d626d290..f93d0ea5b 100644 --- a/internal/authentication/const.go +++ b/internal/authentication/const.go @@ -10,8 +10,10 @@ type Level int const ( // NotAuthenticated if the user is not authenticated yet. NotAuthenticated Level = iota + // OneFactor if the user has passed first factor only. OneFactor + // TwoFactor if the user has passed two factors. TwoFactor ) diff --git a/internal/authorization/authorizer.go b/internal/authorization/authorizer.go index 9a27f48db..7d625864a 100644 --- a/internal/authorization/authorizer.go +++ b/internal/authorization/authorizer.go @@ -53,12 +53,12 @@ func NewAuthorizer(config *schema.Configuration) (authorizer *Authorizer) { } // IsSecondFactorEnabled return true if at least one policy is set to second factor. -func (p Authorizer) IsSecondFactorEnabled() bool { +func (p *Authorizer) IsSecondFactorEnabled() bool { return p.mfa } // GetRequiredLevel retrieve the required level of authorization to access the object. -func (p Authorizer) GetRequiredLevel(subject Subject, object Object) (hasSubjects bool, level Level) { +func (p *Authorizer) GetRequiredLevel(subject Subject, object Object) (hasSubjects bool, level Level) { p.log.Debugf("Check authorization of subject %s and object %s (method %s).", subject.String(), object.String(), object.Method) @@ -78,7 +78,7 @@ func (p Authorizer) GetRequiredLevel(subject Subject, object Object) (hasSubject } // GetRuleMatchResults iterates through the rules and produces a list of RuleMatchResult provided a subject and object. -func (p Authorizer) GetRuleMatchResults(subject Subject, object Object) (results []RuleMatchResult) { +func (p *Authorizer) GetRuleMatchResults(subject Subject, object Object) (results []RuleMatchResult) { skipped := false results = make([]RuleMatchResult, len(p.rules)) diff --git a/internal/authorization/const.go b/internal/authorization/const.go index afda76888..d20fa20f3 100644 --- a/internal/authorization/const.go +++ b/internal/authorization/const.go @@ -6,10 +6,13 @@ type Level int const ( // Bypass bypass level. Bypass Level = iota + // OneFactor one factor level. OneFactor + // TwoFactor two factor level. TwoFactor + // Denied denied level. Denied ) diff --git a/internal/commands/root.go b/internal/commands/root.go index 29fdad27a..0ac6d8843 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -117,7 +117,7 @@ func runServices(ctx *CmdCtx) { ctx.group.Go(func() (err error) { defer func() { if r := recover(); r != nil { - ctx.log.WithError(recoverErr(r)).Errorf("Critical error in server caught (recovered)") + ctx.log.WithError(recoverErr(r)).Errorf("Server (main) critical error caught (recovered)") } }() @@ -143,7 +143,7 @@ func runServices(ctx *CmdCtx) { defer func() { if r := recover(); r != nil { - ctx.log.WithError(recoverErr(r)).Errorf("Critical error in metrics server caught (recovered)") + ctx.log.WithError(recoverErr(r)).Errorf("Server (metrics) critical error caught (recovered)") } }() @@ -165,11 +165,11 @@ func runServices(ctx *CmdCtx) { if ctx.config.AuthenticationBackend.File != nil && ctx.config.AuthenticationBackend.File.Watch { provider := ctx.providers.UserProvider.(*authentication.FileUserProvider) if watcher, err := runServiceFileWatcher(ctx, ctx.config.AuthenticationBackend.File.Path, provider); err != nil { - ctx.log.WithError(err).Errorf("Error opening file watcher") + ctx.log.WithError(err).Errorf("File Watcher (user database) start returned error") } else { defer func(watcher *fsnotify.Watcher) { if err := watcher.Close(); err != nil { - ctx.log.WithError(err).Errorf("Error closing file watcher") + ctx.log.WithError(err).Errorf("File Watcher (user database) close returned error") } }(watcher) } diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 0d966b038..7da204922 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -51,12 +51,6 @@ server: ## Useful to allow overriding of specific static assets. # asset_path: /config/assets/ - ## Enables the pprof endpoint. - enable_pprof: false - - ## Enables the expvars endpoint. - enable_expvars: false - ## Disables writing the health check vars to /app/.healthcheck.env which makes healthcheck.sh return exit code 0. ## This is disabled by default if either /app/.healthcheck.env or /app/healthcheck.sh do not exist. disable_healthcheck: false @@ -104,6 +98,30 @@ server: ## Idle timeout. # idle: 30s + ## Server Endpoints configuration. + ## This section is considered advanced and it SHOULD NOT be configured unless you've read the relevant documentation. + # endpoints: + ## Enables the pprof endpoint. + # enable_pprof: false + + ## Enables the expvars endpoint. + # enable_expvars: false + + ## Configure the authz endpoints. + # authz: + # forward-auth: + # implementation: ForwardAuth + # authn_strategies: [] + # ext-authz: + # implementation: ExtAuthz + # authn_strategies: [] + # auth-request: + # implementation: AuthRequest + # authn_strategies: [] + # legacy: + # implementation: Legacy + # authn_strategies: [] + ## ## Log Configuration ## @@ -505,7 +523,6 @@ authentication_backend: # variant: standard # cost: 12 - ## ## Password Policy Configuration. ## @@ -540,6 +557,23 @@ password_policy: ## Configures the minimum score allowed. min_score: 3 +## +## Privacy Policy Configuration +## +## Parameters used for displaying the privacy policy link and drawer. +privacy_policy: + + ## Enables the display of the privacy policy using the policy_url. + enabled: false + + ## Enables the display of the privacy policy drawer which requires users accept the privacy policy + ## on a per-browser basis. + require_user_acceptance: false + + ## The URL of the privacy policy document. Must be an absolute URL and must have the 'https://' scheme. + ## If the privacy policy enabled option is true, this MUST be provided. + policy_url: '' + ## ## Access Control Configuration ## diff --git a/internal/configuration/deprecation.go b/internal/configuration/deprecation.go index e37750996..b49711898 100644 --- a/internal/configuration/deprecation.go +++ b/internal/configuration/deprecation.go @@ -141,4 +141,18 @@ var deprecations = map[string]Deprecation{ AutoMap: true, MapFunc: nil, }, + "server.enable_pprof": { + Version: model.SemanticVersion{Major: 4, Minor: 38}, + Key: "server.enable_pprof", + NewKey: "server.endpoints.enable_pprof", + AutoMap: true, + MapFunc: nil, + }, + "server.enable_expvars": { + Version: model.SemanticVersion{Major: 4, Minor: 38}, + Key: "server.enable_expvars", + NewKey: "server.endpoints.enable_expvars", + AutoMap: true, + MapFunc: nil, + }, } diff --git a/internal/configuration/schema/configuration.go b/internal/configuration/schema/configuration.go index 3dc891d9a..33e799427 100644 --- a/internal/configuration/schema/configuration.go +++ b/internal/configuration/schema/configuration.go @@ -23,4 +23,5 @@ type Configuration struct { Telemetry TelemetryConfig `koanf:"telemetry"` Webauthn WebauthnConfiguration `koanf:"webauthn"` PasswordPolicy PasswordPolicyConfiguration `koanf:"password_policy"` + PrivacyPolicy PrivacyPolicy `koanf:"privacy_policy"` } diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go index d1d616b44..a5122ee82 100644 --- a/internal/configuration/schema/keys.go +++ b/internal/configuration/schema/keys.go @@ -235,13 +235,17 @@ var Keys = []string{ "server.port", "server.path", "server.asset_path", - "server.enable_pprof", - "server.enable_expvars", "server.disable_healthcheck", "server.tls.certificate", "server.tls.key", "server.tls.client_certificates", "server.headers.csp_template", + "server.endpoints.enable_pprof", + "server.endpoints.enable_expvars", + "server.endpoints.authz", + "server.endpoints.authz.*.implementation", + "server.endpoints.authz.*.authn_strategies", + "server.endpoints.authz.*.authn_strategies[].name", "server.buffers.read", "server.buffers.write", "server.timeouts.read", @@ -268,4 +272,7 @@ var Keys = []string{ "password_policy.standard.require_special", "password_policy.zxcvbn.enabled", "password_policy.zxcvbn.min_score", + "privacy_policy.enabled", + "privacy_policy.require_user_acceptance", + "privacy_policy.policy_url", } diff --git a/internal/configuration/schema/privacy_policy.go b/internal/configuration/schema/privacy_policy.go new file mode 100644 index 000000000..500f6c0cb --- /dev/null +++ b/internal/configuration/schema/privacy_policy.go @@ -0,0 +1,12 @@ +package schema + +import ( + "net/url" +) + +// PrivacyPolicy is the privacy policy configuration. +type PrivacyPolicy struct { + Enabled bool `koanf:"enabled"` + RequireUserAcceptance bool `koanf:"require_user_acceptance"` + PolicyURL *url.URL `koanf:"policy_url"` +} diff --git a/internal/configuration/schema/server.go b/internal/configuration/schema/server.go index c6ad3e5d5..c786567b0 100644 --- a/internal/configuration/schema/server.go +++ b/internal/configuration/schema/server.go @@ -10,26 +10,45 @@ type ServerConfiguration struct { Port int `koanf:"port"` Path string `koanf:"path"` AssetPath string `koanf:"asset_path"` - EnablePprof bool `koanf:"enable_pprof"` - EnableExpvars bool `koanf:"enable_expvars"` DisableHealthcheck bool `koanf:"disable_healthcheck"` - TLS ServerTLSConfiguration `koanf:"tls"` - Headers ServerHeadersConfiguration `koanf:"headers"` + TLS ServerTLS `koanf:"tls"` + Headers ServerHeaders `koanf:"headers"` + Endpoints ServerEndpoints `koanf:"endpoints"` Buffers ServerBuffers `koanf:"buffers"` Timeouts ServerTimeouts `koanf:"timeouts"` } -// ServerTLSConfiguration represents the configuration of the http servers TLS options. -type ServerTLSConfiguration struct { +// ServerEndpoints is the endpoints configuration for the HTTP server. +type ServerEndpoints struct { + EnablePprof bool `koanf:"enable_pprof"` + EnableExpvars bool `koanf:"enable_expvars"` + + Authz map[string]ServerAuthzEndpoint `koanf:"authz"` +} + +// ServerAuthzEndpoint is the Authz endpoints configuration for the HTTP server. +type ServerAuthzEndpoint struct { + Implementation string `koanf:"implementation"` + + AuthnStrategies []ServerAuthzEndpointAuthnStrategy `koanf:"authn_strategies"` +} + +// ServerAuthzEndpointAuthnStrategy is the Authz endpoints configuration for the HTTP server. +type ServerAuthzEndpointAuthnStrategy struct { + Name string `koanf:"name"` +} + +// ServerTLS represents the configuration of the http servers TLS options. +type ServerTLS struct { Certificate string `koanf:"certificate"` Key string `koanf:"key"` ClientCertificates []string `koanf:"client_certificates"` } -// ServerHeadersConfiguration represents the customization of the http server headers. -type ServerHeadersConfiguration struct { +// ServerHeaders represents the customization of the http server headers. +type ServerHeaders struct { CSPTemplate string `koanf:"csp_template"` } @@ -46,4 +65,44 @@ var DefaultServerConfiguration = ServerConfiguration{ Write: time.Second * 6, Idle: time.Second * 30, }, + Endpoints: ServerEndpoints{ + Authz: map[string]ServerAuthzEndpoint{ + "legacy": { + Implementation: "Legacy", + }, + "auth-request": { + Implementation: "AuthRequest", + AuthnStrategies: []ServerAuthzEndpointAuthnStrategy{ + { + Name: "HeaderAuthRequestProxyAuthorization", + }, + { + Name: "CookieSession", + }, + }, + }, + "forward-auth": { + Implementation: "ForwardAuth", + AuthnStrategies: []ServerAuthzEndpointAuthnStrategy{ + { + Name: "HeaderProxyAuthorization", + }, + { + Name: "CookieSession", + }, + }, + }, + "ext-authz": { + Implementation: "ExtAuthz", + AuthnStrategies: []ServerAuthzEndpointAuthnStrategy{ + { + Name: "HeaderProxyAuthorization", + }, + { + Name: "CookieSession", + }, + }, + }, + }, + }, } diff --git a/internal/configuration/test_resources/config.yml b/internal/configuration/test_resources/config.yml index ea48a847f..3d6437343 100644 --- a/internal/configuration/test_resources/config.yml +++ b/internal/configuration/test_resources/config.yml @@ -4,6 +4,25 @@ default_redirection_url: https://home.example.com:8080/ server: host: 127.0.0.1 port: 9091 + endpoints: + authz: + forward-auth: + implementation: ForwardAuth + authn_strategies: + - name: HeaderProxyAuthorization + - name: CookieSession + ext-authz: + implementation: ExtAuthz + authn_strategies: + - name: HeaderProxyAuthorization + - name: CookieSession + auth-request: + implementation: AuthRequest + authn_strategies: + - name: HeaderAuthRequestProxyAuthorization + - name: CookieSession + legacy: + implementation: Legacy log: level: debug diff --git a/internal/configuration/validator/configuration.go b/internal/configuration/validator/configuration.go index 703054942..13045b86a 100644 --- a/internal/configuration/validator/configuration.go +++ b/internal/configuration/validator/configuration.go @@ -68,6 +68,8 @@ func ValidateConfiguration(config *schema.Configuration, validator *schema.Struc ValidateNTP(config, validator) ValidatePasswordPolicy(&config.PasswordPolicy, validator) + + ValidatePrivacyPolicy(&config.PrivacyPolicy, validator) } func validateDefault2FAMethod(config *schema.Configuration, validator *schema.StructValidator) { diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 9f68a32cb..7573a02cc 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -286,6 +286,14 @@ const ( errFmtServerPathNoForwardSlashes = "server: option 'path' must not contain any forward slashes" errFmtServerPathAlphaNum = "server: option 'path' must only contain alpha numeric characters" + + errFmtServerEndpointsAuthzImplementation = "server: endpoints: authz: %s: option 'implementation' must be one of '%s' but is configured as '%s'" + errFmtServerEndpointsAuthzStrategy = "server: endpoints: authz: %s: authn_strategies: option 'name' must be one of '%s' but is configured as '%s'" + errFmtServerEndpointsAuthzStrategyDuplicate = "server: endpoints: authz: %s: authn_strategies: duplicate strategy name detected with name '%s'" + errFmtServerEndpointsAuthzPrefixDuplicate = "server: endpoints: authz: %s: endpoint starts with the same prefix as the '%s' endpoint with the '%s' implementation which accepts prefixes as part of its implementation" + errFmtServerEndpointsAuthzInvalidName = "server: endpoints: authz: %s: contains invalid characters" + + errFmtServerEndpointsAuthzLegacyInvalidImplementation = "server: endpoints: authz: %s: option 'implementation' is invalid: the endpoint with the name 'legacy' must use the 'Legacy' implementation" ) const ( @@ -294,22 +302,17 @@ const ( errFmtPasswordPolicyZXCVBNMinScoreInvalid = "password_policy: zxcvbn: option 'min_score' is invalid: must be between 1 and 4 but it's configured as %d" ) +const ( + errPrivacyPolicyEnabledWithoutURL = "privacy_policy: option 'policy_url' must be provided when the option 'enabled' is true" + errFmtPrivacyPolicyURLNotHTTPS = "privacy_policy: option 'policy_url' must have the 'https' scheme but it's configured as '%s'" +) + const ( errFmtDuoMissingOption = "duo_api: option '%s' is required when duo is enabled but it is missing" ) // Error constants. const ( - /* - errFmtDeprecatedConfigurationKey = "the %s configuration option is deprecated and will be " + - "removed in %s, please use %s instead" - - Uncomment for use when deprecating keys. - - TODO: Create a method from within Koanf to automatically remap deprecated keys and produce warnings. - TODO (cont): The main consideration is making sure we do not overwrite the destination key name if it already exists. - */ - errFmtInvalidDefault2FAMethod = "option 'default_2fa_method' is configured as '%s' but must be one of " + "the following values: '%s'" errFmtInvalidDefault2FAMethodDisabled = "option 'default_2fa_method' is configured as '%s' " + @@ -337,6 +340,17 @@ var ( validLDAPImplementations = []string{schema.LDAPImplementationCustom, schema.LDAPImplementationActiveDirectory, schema.LDAPImplementationFreeIPA, schema.LDAPImplementationLLDAP} ) +const ( + legacy = "legacy" + authzImplementationLegacy = "Legacy" + authzImplementationExtAuthz = "ExtAuthz" +) + +var ( + validAuthzImplementations = []string{"AuthRequest", "ForwardAuth", authzImplementationExtAuthz, authzImplementationLegacy} + validAuthzAuthnStrategies = []string{"CookieSession", "HeaderAuthorization", "HeaderProxyAuthorization", "HeaderAuthRequestProxyAuthorization", "HeaderLegacy"} +) + var ( validArgon2Variants = []string{"argon2id", "id", "argon2i", "i", "argon2d", "d"} validSHA2CryptVariants = []string{digestSHA256, digestSHA512} @@ -374,8 +388,9 @@ var ( ) var ( - reKeyReplacer = regexp.MustCompile(`\[\d+]`) - reDomainCharacters = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+)+[a-z0-9]$`) + reKeyReplacer = regexp.MustCompile(`\[\d+]`) + reDomainCharacters = regexp.MustCompile(`^[a-z0-9-]+(\.[a-z0-9-]+)+[a-z0-9]$`) + reAuthzEndpointName = regexp.MustCompile(`^[a-zA-Z](([a-zA-Z0-9/\._-]*)([a-zA-Z]))?$`) ) var replacedKeys = map[string]string{ diff --git a/internal/configuration/validator/keys.go b/internal/configuration/validator/keys.go index e3e5fdcf7..67b9d964d 100644 --- a/internal/configuration/validator/keys.go +++ b/internal/configuration/validator/keys.go @@ -3,6 +3,7 @@ package validator import ( "errors" "fmt" + "regexp" "strings" "github.com/authelia/authelia/v4/internal/configuration/schema" @@ -13,6 +14,20 @@ import ( func ValidateKeys(keys []string, prefix string, validator *schema.StructValidator) { var errStrings []string + var patterns []*regexp.Regexp + + for _, key := range schema.Keys { + pattern, _ := NewKeyPattern(key) + + switch { + case pattern == nil: + continue + default: + patterns = append(patterns, pattern) + } + } + +KEYS: for _, key := range keys { expectedKey := reKeyReplacer.ReplaceAllString(key, "[]") @@ -25,6 +40,12 @@ func ValidateKeys(keys []string, prefix string, validator *schema.StructValidato continue } + for _, p := range patterns { + if p.MatchString(expectedKey) { + continue KEYS + } + } + if err, ok := specificErrorKeys[expectedKey]; ok { if !utils.IsStringInSlice(err, errStrings) { errStrings = append(errStrings, err) @@ -42,3 +63,48 @@ func ValidateKeys(keys []string, prefix string, validator *schema.StructValidato validator.Push(errors.New(err)) } } + +// NewKeyPattern returns patterns which are required to match key patterns. +func NewKeyPattern(key string) (pattern *regexp.Regexp, err error) { + switch { + case strings.Contains(key, ".*."): + return NewKeyMapPattern(key) + default: + return nil, nil + } +} + +// NewKeyMapPattern returns a pattern required to match map keys. +func NewKeyMapPattern(key string) (pattern *regexp.Regexp, err error) { + parts := strings.Split(key, ".*.") + + buf := &strings.Builder{} + + buf.WriteString("^") + + n := len(parts) - 1 + + for i, part := range parts { + if i != 0 { + buf.WriteString("\\.") + } + + for _, r := range part { + switch r { + case '[', ']', '.', '{', '}': + buf.WriteRune('\\') + fallthrough + default: + buf.WriteRune(r) + } + } + + if i < n { + buf.WriteString("\\.[a-z0-9]([a-z0-9-_]+)?[a-z0-9]") + } + } + + buf.WriteString("$") + + return regexp.Compile(buf.String()) +} diff --git a/internal/configuration/validator/password_policy.go b/internal/configuration/validator/password_policy.go index 0951c4bf5..33e774fd2 100644 --- a/internal/configuration/validator/password_policy.go +++ b/internal/configuration/validator/password_policy.go @@ -7,7 +7,7 @@ import ( "github.com/authelia/authelia/v4/internal/utils" ) -// ValidatePasswordPolicy validates and update Password Policy configuration. +// ValidatePasswordPolicy validates and updates the Password Policy configuration. func ValidatePasswordPolicy(config *schema.PasswordPolicyConfiguration, validator *schema.StructValidator) { if !utils.IsBoolCountLessThanN(1, true, config.Standard.Enabled, config.ZXCVBN.Enabled) { validator.Push(fmt.Errorf(errPasswordPolicyMultipleDefined)) diff --git a/internal/configuration/validator/privacy_policy.go b/internal/configuration/validator/privacy_policy.go new file mode 100644 index 000000000..6b584f76b --- /dev/null +++ b/internal/configuration/validator/privacy_policy.go @@ -0,0 +1,23 @@ +package validator + +import ( + "fmt" + + "github.com/authelia/authelia/v4/internal/configuration/schema" +) + +// ValidatePrivacyPolicy validates and updates the Privacy Policy configuration. +func ValidatePrivacyPolicy(config *schema.PrivacyPolicy, validator *schema.StructValidator) { + if !config.Enabled { + return + } + + switch config.PolicyURL { + case nil: + validator.Push(fmt.Errorf(errPrivacyPolicyEnabledWithoutURL)) + default: + if config.PolicyURL.Scheme != schemeHTTPS { + validator.Push(fmt.Errorf(errFmtPrivacyPolicyURLNotHTTPS, config.PolicyURL.Scheme)) + } + } +} diff --git a/internal/configuration/validator/privacy_policy_test.go b/internal/configuration/validator/privacy_policy_test.go new file mode 100644 index 000000000..d922c75b6 --- /dev/null +++ b/internal/configuration/validator/privacy_policy_test.go @@ -0,0 +1,41 @@ +package validator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/authelia/authelia/v4/internal/configuration/schema" +) + +func TestValidatePrivacyPolicy(t *testing.T) { + testCases := []struct { + name string + have schema.PrivacyPolicy + expected string + }{ + {"ShouldValidateDefaultConfig", schema.PrivacyPolicy{}, ""}, + {"ShouldValidateValidEnabledPolicy", schema.PrivacyPolicy{Enabled: true, PolicyURL: MustParseURL("https://example.com/privacy")}, ""}, + {"ShouldValidateValidEnabledPolicyWithUserAcceptance", schema.PrivacyPolicy{Enabled: true, RequireUserAcceptance: true, PolicyURL: MustParseURL("https://example.com/privacy")}, ""}, + {"ShouldNotValidateOnInvalidScheme", schema.PrivacyPolicy{Enabled: true, PolicyURL: MustParseURL("http://example.com/privacy")}, "privacy_policy: option 'policy_url' must have the 'https' scheme but it's configured as 'http'"}, + {"ShouldNotValidateOnMissingURL", schema.PrivacyPolicy{Enabled: true}, "privacy_policy: option 'policy_url' must be provided when the option 'enabled' is true"}, + } + + validator := schema.NewStructValidator() + + for _, tc := range testCases { + validator.Clear() + + t.Run(tc.name, func(t *testing.T) { + ValidatePrivacyPolicy(&tc.have, validator) + + assert.Len(t, validator.Warnings(), 0) + + if tc.expected == "" { + assert.Len(t, validator.Errors(), 0) + } else { + assert.EqualError(t, validator.Errors()[0], tc.expected) + } + }) + } +} diff --git a/internal/configuration/validator/server.go b/internal/configuration/validator/server.go index b0529ab0e..66a12d150 100644 --- a/internal/configuration/validator/server.go +++ b/internal/configuration/validator/server.go @@ -3,6 +3,7 @@ package validator import ( "fmt" "path" + "sort" "strings" "github.com/authelia/authelia/v4/internal/configuration/schema" @@ -89,4 +90,97 @@ func ValidateServer(config *schema.Configuration, validator *schema.StructValida if config.Server.Timeouts.Idle <= 0 { config.Server.Timeouts.Idle = schema.DefaultServerConfiguration.Timeouts.Idle } + + ValidateServerEndpoints(config, validator) +} + +// ValidateServerEndpoints configures the default endpoints and checks the configuration of custom endpoints. +func ValidateServerEndpoints(config *schema.Configuration, validator *schema.StructValidator) { + if config.Server.Endpoints.EnableExpvars { + validator.PushWarning(fmt.Errorf("server: endpoints: option 'enable_expvars' should not be enabled in production")) + } + + if config.Server.Endpoints.EnablePprof { + validator.PushWarning(fmt.Errorf("server: endpoints: option 'enable_pprof' should not be enabled in production")) + } + + if len(config.Server.Endpoints.Authz) == 0 { + config.Server.Endpoints.Authz = schema.DefaultServerConfiguration.Endpoints.Authz + + return + } + + authzs := make([]string, 0, len(config.Server.Endpoints.Authz)) + + for name := range config.Server.Endpoints.Authz { + authzs = append(authzs, name) + } + + sort.Strings(authzs) + + for _, name := range authzs { + endpoint := config.Server.Endpoints.Authz[name] + + validateServerEndpointsAuthzEndpoint(config, name, endpoint, validator) + + for _, oName := range authzs { + oEndpoint := config.Server.Endpoints.Authz[oName] + + if oName == name || oName == legacy { + continue + } + + switch oEndpoint.Implementation { + case authzImplementationLegacy, authzImplementationExtAuthz: + if strings.HasPrefix(name, oName+"/") { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzPrefixDuplicate, name, oName, oEndpoint.Implementation)) + } + default: + continue + } + } + + validateServerEndpointsAuthzStrategies(name, endpoint.AuthnStrategies, validator) + } +} + +func validateServerEndpointsAuthzEndpoint(config *schema.Configuration, name string, endpoint schema.ServerAuthzEndpoint, validator *schema.StructValidator) { + if name == legacy { + switch endpoint.Implementation { + case authzImplementationLegacy: + break + case "": + endpoint.Implementation = authzImplementationLegacy + + config.Server.Endpoints.Authz[name] = endpoint + default: + if !utils.IsStringInSlice(endpoint.Implementation, validAuthzImplementations) { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzImplementation, name, strings.Join(validAuthzImplementations, "', '"), endpoint.Implementation)) + } else { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzLegacyInvalidImplementation, name)) + } + } + } else if !utils.IsStringInSlice(endpoint.Implementation, validAuthzImplementations) { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzImplementation, name, strings.Join(validAuthzImplementations, "', '"), endpoint.Implementation)) + } + + if !reAuthzEndpointName.MatchString(name) { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzInvalidName, name)) + } +} + +func validateServerEndpointsAuthzStrategies(name string, strategies []schema.ServerAuthzEndpointAuthnStrategy, validator *schema.StructValidator) { + names := make([]string, len(strategies)) + + for _, strategy := range strategies { + if utils.IsStringInSlice(strategy.Name, names) { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzStrategyDuplicate, name, strategy.Name)) + } + + names = append(names, strategy.Name) + + if !utils.IsStringInSlice(strategy.Name, validAuthzAuthnStrategies) { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzStrategy, name, strings.Join(validAuthzAuthnStrategies, "', '"), strategy.Name)) + } + } } diff --git a/internal/configuration/validator/server_test.go b/internal/configuration/validator/server_test.go index bbbdb4010..c70e7124e 100644 --- a/internal/configuration/validator/server_test.go +++ b/internal/configuration/validator/server_test.go @@ -29,8 +29,9 @@ func TestShouldSetDefaultServerValues(t *testing.T) { assert.Equal(t, schema.DefaultServerConfiguration.TLS.Key, config.Server.TLS.Key) assert.Equal(t, schema.DefaultServerConfiguration.TLS.Certificate, config.Server.TLS.Certificate) assert.Equal(t, schema.DefaultServerConfiguration.Path, config.Server.Path) - assert.Equal(t, schema.DefaultServerConfiguration.EnableExpvars, config.Server.EnableExpvars) - assert.Equal(t, schema.DefaultServerConfiguration.EnablePprof, config.Server.EnablePprof) + assert.Equal(t, schema.DefaultServerConfiguration.Endpoints.EnableExpvars, config.Server.Endpoints.EnableExpvars) + assert.Equal(t, schema.DefaultServerConfiguration.Endpoints.EnablePprof, config.Server.Endpoints.EnablePprof) + assert.Equal(t, schema.DefaultServerConfiguration.Endpoints.Authz, config.Server.Endpoints.Authz) } func TestShouldSetDefaultConfig(t *testing.T) { @@ -278,3 +279,165 @@ func TestShouldValidateAndUpdatePort(t *testing.T) { require.Len(t, validator.Errors(), 0) assert.Equal(t, 9091, config.Server.Port) } + +func TestServerEndpointsDevelShouldWarn(t *testing.T) { + config := &schema.Configuration{ + Server: schema.ServerConfiguration{ + Endpoints: schema.ServerEndpoints{ + EnablePprof: true, + EnableExpvars: true, + }, + }, + } + + validator := schema.NewStructValidator() + + ValidateServer(config, validator) + + require.Len(t, validator.Warnings(), 2) + assert.Len(t, validator.Errors(), 0) + + assert.EqualError(t, validator.Warnings()[0], "server: endpoints: option 'enable_expvars' should not be enabled in production") + assert.EqualError(t, validator.Warnings()[1], "server: endpoints: option 'enable_pprof' should not be enabled in production") +} + +func TestServerAuthzEndpointErrors(t *testing.T) { + testCases := []struct { + name string + have map[string]schema.ServerAuthzEndpoint + errs []string + }{ + {"ShouldAllowDefaultEndpoints", schema.DefaultServerConfiguration.Endpoints.Authz, nil}, + {"ShouldAllowSetDefaultEndpoints", nil, nil}, + { + "ShouldErrorOnInvalidEndpointImplementations", + map[string]schema.ServerAuthzEndpoint{ + "example": {Implementation: "zero"}, + }, + []string{"server: endpoints: authz: example: option 'implementation' must be one of 'AuthRequest', 'ForwardAuth', 'ExtAuthz', 'Legacy' but is configured as 'zero'"}, + }, + { + "ShouldErrorOnInvalidEndpointImplementationLegacy", + map[string]schema.ServerAuthzEndpoint{ + "legacy": {Implementation: "zero"}, + }, + []string{"server: endpoints: authz: legacy: option 'implementation' must be one of 'AuthRequest', 'ForwardAuth', 'ExtAuthz', 'Legacy' but is configured as 'zero'"}, + }, + { + "ShouldErrorOnInvalidEndpointLegacyImplementation", + map[string]schema.ServerAuthzEndpoint{ + "legacy": {Implementation: "ExtAuthz"}, + }, + []string{"server: endpoints: authz: legacy: option 'implementation' is invalid: the endpoint with the name 'legacy' must use the 'Legacy' implementation"}, + }, + { + "ShouldErrorOnInvalidAuthnStrategies", + map[string]schema.ServerAuthzEndpoint{ + "example": {Implementation: "ExtAuthz", AuthnStrategies: []schema.ServerAuthzEndpointAuthnStrategy{{Name: "bad-name"}}}, + }, + []string{"server: endpoints: authz: example: authn_strategies: option 'name' must be one of 'CookieSession', 'HeaderAuthorization', 'HeaderProxyAuthorization', 'HeaderAuthRequestProxyAuthorization', 'HeaderLegacy' but is configured as 'bad-name'"}, + }, + { + "ShouldErrorOnDuplicateName", + map[string]schema.ServerAuthzEndpoint{ + "example": {Implementation: "ExtAuthz", AuthnStrategies: []schema.ServerAuthzEndpointAuthnStrategy{{Name: "CookieSession"}, {Name: "CookieSession"}}}, + }, + []string{"server: endpoints: authz: example: authn_strategies: duplicate strategy name detected with name 'CookieSession'"}, + }, + { + "ShouldErrorOnInvalidChars", + map[string]schema.ServerAuthzEndpoint{ + "/abc": {Implementation: "ForwardAuth"}, + "/abc/": {Implementation: "ForwardAuth"}, + "abc/": {Implementation: "ForwardAuth"}, + "1abc": {Implementation: "ForwardAuth"}, + "1abc1": {Implementation: "ForwardAuth"}, + "abc1": {Implementation: "ForwardAuth"}, + "-abc": {Implementation: "ForwardAuth"}, + "-abc-": {Implementation: "ForwardAuth"}, + "abc-": {Implementation: "ForwardAuth"}, + }, + []string{ + "server: endpoints: authz: -abc: contains invalid characters", + "server: endpoints: authz: -abc-: contains invalid characters", + "server: endpoints: authz: /abc: contains invalid characters", + "server: endpoints: authz: /abc/: contains invalid characters", + "server: endpoints: authz: 1abc: contains invalid characters", + "server: endpoints: authz: 1abc1: contains invalid characters", + "server: endpoints: authz: abc-: contains invalid characters", + "server: endpoints: authz: abc/: contains invalid characters", + "server: endpoints: authz: abc1: contains invalid characters", + }, + }, + { + "ShouldErrorOnEndpointsWithDuplicatePrefix", + map[string]schema.ServerAuthzEndpoint{ + "apple": {Implementation: "ForwardAuth"}, + "apple/abc": {Implementation: "ForwardAuth"}, + "pear/abc": {Implementation: "ExtAuthz"}, + "pear": {Implementation: "ExtAuthz"}, + "another": {Implementation: "ExtAuthz"}, + "another/test": {Implementation: "ForwardAuth"}, + "anotherb/test": {Implementation: "ForwardAuth"}, + "anothe": {Implementation: "ExtAuthz"}, + "anotherc/test": {Implementation: "ForwardAuth"}, + "anotherc": {Implementation: "ExtAuthz"}, + "anotherd/test": {Implementation: "ForwardAuth"}, + "anotherd": {Implementation: "Legacy"}, + "anothere/test": {Implementation: "ExtAuthz"}, + "anothere": {Implementation: "ExtAuthz"}, + }, + []string{ + "server: endpoints: authz: another/test: endpoint starts with the same prefix as the 'another' endpoint with the 'ExtAuthz' implementation which accepts prefixes as part of its implementation", + "server: endpoints: authz: anotherc/test: endpoint starts with the same prefix as the 'anotherc' endpoint with the 'ExtAuthz' implementation which accepts prefixes as part of its implementation", + "server: endpoints: authz: anotherd/test: endpoint starts with the same prefix as the 'anotherd' endpoint with the 'Legacy' implementation which accepts prefixes as part of its implementation", + "server: endpoints: authz: anothere/test: endpoint starts with the same prefix as the 'anothere' endpoint with the 'ExtAuthz' implementation which accepts prefixes as part of its implementation", + "server: endpoints: authz: pear/abc: endpoint starts with the same prefix as the 'pear' endpoint with the 'ExtAuthz' implementation which accepts prefixes as part of its implementation", + }, + }, + } + + validator := schema.NewStructValidator() + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + validator.Clear() + + config := newDefaultConfig() + + config.Server.Endpoints.Authz = tc.have + + ValidateServerEndpoints(&config, validator) + + if tc.errs == nil { + assert.Len(t, validator.Warnings(), 0) + assert.Len(t, validator.Errors(), 0) + } else { + require.Len(t, validator.Errors(), len(tc.errs)) + + for i, expected := range tc.errs { + assert.EqualError(t, validator.Errors()[i], expected) + } + } + }) + } +} + +func TestServerAuthzEndpointLegacyAsImplementationLegacyWhenBlank(t *testing.T) { + have := map[string]schema.ServerAuthzEndpoint{ + "legacy": {}, + } + + config := newDefaultConfig() + + config.Server.Endpoints.Authz = have + + validator := schema.NewStructValidator() + + ValidateServerEndpoints(&config, validator) + + assert.Len(t, validator.Warnings(), 0) + assert.Len(t, validator.Errors(), 0) + + assert.Equal(t, authzImplementationLegacy, config.Server.Endpoints.Authz[legacy].Implementation) +} diff --git a/internal/duo/duo.go b/internal/duo/duo.go index 345190904..b34290c7b 100644 --- a/internal/duo/duo.go +++ b/internal/duo/duo.go @@ -7,6 +7,7 @@ import ( duoapi "github.com/duosecurity/duo_api_golang" "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" ) // NewDuoAPI create duo API instance. @@ -16,8 +17,8 @@ func NewDuoAPI(duoAPI *duoapi.DuoApi) *APIImpl { } } -// Call call to the DuoAPI. -func (d *APIImpl) Call(ctx *middlewares.AutheliaCtx, values url.Values, method string, path string) (*Response, error) { +// Call performs a request to the DuoAPI. +func (d *APIImpl) Call(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, values url.Values, method string, path string) (*Response, error) { var response Response _, responseBytes, err := d.DuoApi.SignedCall(method, path, values) @@ -25,7 +26,7 @@ func (d *APIImpl) Call(ctx *middlewares.AutheliaCtx, values url.Values, method s return nil, err } - ctx.Logger.Tracef("Duo endpoint: %s response raw data for %s from IP %s: %s", path, ctx.GetSession().Username, ctx.RemoteIP().String(), string(responseBytes)) + ctx.Logger.Tracef("Duo endpoint: %s response raw data for %s from IP %s: %s", path, userSession.Username, ctx.RemoteIP().String(), string(responseBytes)) err = json.Unmarshal(responseBytes, &response) if err != nil { @@ -35,18 +36,18 @@ func (d *APIImpl) Call(ctx *middlewares.AutheliaCtx, values url.Values, method s if response.Stat == "FAIL" { ctx.Logger.Warnf( "Duo Push Auth failed to process the auth request for %s from %s: %s (%s), error code %d.", - ctx.GetSession().Username, ctx.RemoteIP().String(), + userSession.Username, ctx.RemoteIP().String(), response.Message, response.MessageDetail, response.Code) } return &response, nil } -// PreAuthCall call to the DuoAPI. -func (d *APIImpl) PreAuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*PreAuthResponse, error) { +// PreAuthCall performs a preauth request to the DuoAPI. +func (d *APIImpl) PreAuthCall(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, values url.Values) (*PreAuthResponse, error) { var preAuthResponse PreAuthResponse - response, err := d.Call(ctx, values, "POST", "/auth/v2/preauth") + response, err := d.Call(ctx, userSession, values, "POST", "/auth/v2/preauth") if err != nil { return nil, err } @@ -59,11 +60,11 @@ func (d *APIImpl) PreAuthCall(ctx *middlewares.AutheliaCtx, values url.Values) ( return &preAuthResponse, nil } -// AuthCall call to the DuoAPI. -func (d *APIImpl) AuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*AuthResponse, error) { +// AuthCall performs an auth request to the DuoAPI. +func (d *APIImpl) AuthCall(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, values url.Values) (*AuthResponse, error) { var authResponse AuthResponse - response, err := d.Call(ctx, values, "POST", "/auth/v2/auth") + response, err := d.Call(ctx, userSession, values, "POST", "/auth/v2/auth") if err != nil { return nil, err } diff --git a/internal/duo/types.go b/internal/duo/types.go index 54599cd02..c865bc49e 100644 --- a/internal/duo/types.go +++ b/internal/duo/types.go @@ -7,13 +7,14 @@ import ( duoapi "github.com/duosecurity/duo_api_golang" "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" ) // API interface wrapping duo api library for testing purpose. type API interface { - Call(ctx *middlewares.AutheliaCtx, values url.Values, method string, path string) (*Response, error) - PreAuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*PreAuthResponse, error) - AuthCall(ctx *middlewares.AutheliaCtx, values url.Values) (*AuthResponse, error) + Call(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, values url.Values, method string, path string) (response *Response, err error) + PreAuthCall(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, values url.Values) (response *PreAuthResponse, err error) + AuthCall(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, values url.Values) (response *AuthResponse, err error) } // APIImpl implementation of DuoAPI interface. diff --git a/internal/handlers/const.go b/internal/handlers/const.go index a34d81ed6..01c853f42 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -1,8 +1,6 @@ package handlers import ( - "time" - "github.com/valyala/fasthttp" ) @@ -18,9 +16,22 @@ const ( ) var ( - headerAuthorization = []byte(fasthttp.HeaderAuthorization) - headerProxyAuthorization = []byte(fasthttp.HeaderProxyAuthorization) + headerAuthorization = []byte(fasthttp.HeaderAuthorization) + headerWWWAuthenticate = []byte(fasthttp.HeaderWWWAuthenticate) + headerProxyAuthorization = []byte(fasthttp.HeaderProxyAuthorization) + headerProxyAuthenticate = []byte(fasthttp.HeaderProxyAuthenticate) +) + +const ( + headerAuthorizationSchemeBasic = "basic" +) + +var ( + headerValueAuthenticateBasic = []byte(`Basic realm="Authorization Required"`) +) + +var ( headerSessionUsername = []byte("Session-Username") headerRemoteUser = []byte("Remote-User") headerRemoteGroups = []byte("Remote-Groups") @@ -30,7 +41,9 @@ var ( const ( queryArgRD = "rd" + queryArgRM = "rm" queryArgID = "id" + queryArgAuth = "auth" queryArgConsentID = "consent_id" queryArgWorkflow = "workflow" queryArgWorkflowID = "workflow_id" @@ -38,16 +51,14 @@ const ( var ( qryArgID = []byte(queryArgID) + qryArgRD = []byte(queryArgRD) + qryArgAuth = []byte(queryArgAuth) qryArgConsentID = []byte(queryArgConsentID) ) -const ( - // Forbidden means the user is forbidden the access to a resource. - Forbidden authorizationMatching = iota - // NotAuthorized means the user can access the resource with more permissions. - NotAuthorized authorizationMatching = iota - // Authorized means the user is authorized given her current permissions. - Authorized authorizationMatching = iota +var ( + qryValueBasic = []byte("basic") + qryValueEmpty = []byte("") ) const ( @@ -109,13 +120,6 @@ const ( logFmtErrConsentGenerate = logFmtConsentPrefix + "could not be processed: error occurred generating consent: %+v" ) -const ( - testInactivity = time.Second * 10 - testRedirectionURL = "http://redirection.local" - testUsername = "john" - exampleDotCom = "example.com" -) - // Duo constants. const ( allow = "allow" @@ -124,8 +128,6 @@ const ( auth = "auth" ) -const authPrefix = "Basic " - const ldapPasswordComplexityCode = "0000052D." var ldapPasswordComplexityCodes = []string{ diff --git a/internal/handlers/const_test.go b/internal/handlers/const_test.go new file mode 100644 index 000000000..4ff49b9bb --- /dev/null +++ b/internal/handlers/const_test.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "time" + + "github.com/valyala/fasthttp" +) + +var ( + testRequestMethods = []string{ + fasthttp.MethodOptions, fasthttp.MethodHead, fasthttp.MethodGet, + fasthttp.MethodDelete, fasthttp.MethodPatch, fasthttp.MethodPost, + fasthttp.MethodPut, fasthttp.MethodConnect, fasthttp.MethodTrace, + } + + testXHR = map[string]bool{ + testWithoutAccept: false, + testWithXHRHeader: true, + } +) + +const ( + testXOriginalMethod = "X-Original-Method" + testXOriginalUrl = "X-Original-Url" + testBypass = "bypass" + testWithoutAccept = "WithoutAccept" + testWithXHRHeader = "WithXHRHeader" +) + +const ( + testInactivity = time.Second * 10 + testRedirectionURL = "http://redirection.local" + testUsername = "john" + exampleDotCom = "example.com" +) diff --git a/internal/handlers/duo.go b/internal/handlers/duo.go index 098907bda..ceb4b5240 100644 --- a/internal/handlers/duo.go +++ b/internal/handlers/duo.go @@ -5,16 +5,16 @@ import ( "github.com/authelia/authelia/v4/internal/duo" "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/utils" ) // DuoPreAuth helper function for retrieving supported devices and capabilities from duo api. -func DuoPreAuth(ctx *middlewares.AutheliaCtx, duoAPI duo.API) (string, string, []DuoDevice, string, error) { - userSession := ctx.GetSession() +func DuoPreAuth(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API) (result, message string, devices []DuoDevice, enrollURL string, err error) { values := url.Values{} values.Set("username", userSession.Username) - preAuthResponse, err := duoAPI.PreAuthCall(ctx, values) + preAuthResponse, err := duoAPI.PreAuthCall(ctx, userSession, values) if err != nil { return "", "", nil, "", err } diff --git a/internal/handlers/handler_authz.go b/internal/handlers/handler_authz.go new file mode 100644 index 000000000..c143bd3bc --- /dev/null +++ b/internal/handlers/handler_authz.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "fmt" + "net/url" + + "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/utils" +) + +// Handler is the middlewares.RequestHandler for Authz. +func (authz *Authz) Handler(ctx *middlewares.AutheliaCtx) { + var ( + object authorization.Object + autheliaURL *url.URL + provider *session.Session + err error + ) + + if object, err = authz.handleGetObject(ctx); err != nil { + ctx.Logger.Errorf("Error getting original request object: %v", err) + + ctx.ReplyUnauthorized() + + return + } + + if !utils.IsURISecure(object.URL) { + ctx.Logger.Errorf("Target URL '%s' has an insecure scheme '%s', only the 'https' and 'wss' schemes are supported so session cookies can be transmitted securely", object.URL.String(), object.URL.Scheme) + + ctx.ReplyUnauthorized() + + return + } + + if provider, err = ctx.GetSessionProviderByTargetURL(object.URL); err != nil { + ctx.Logger.WithError(err).Errorf("Target URL '%s' does not appear to be configured as a session domain", object.URL.String()) + + ctx.ReplyUnauthorized() + + return + } + + if autheliaURL, err = authz.getAutheliaURL(ctx, provider); err != nil { + ctx.Logger.WithError(err).Error("Error occurred trying to determine the URL of the portal") + + ctx.ReplyUnauthorized() + + return + } + + var ( + authn Authn + strategy AuthnStrategy + ) + + if authn, strategy, err = authz.authn(ctx, provider); err != nil { + authn.Object = object + + ctx.Logger.WithError(err).Error("Error occurred while attempting to authenticate a request") + + switch strategy { + case nil: + ctx.ReplyUnauthorized() + default: + strategy.HandleUnauthorized(ctx, &authn, authz.getRedirectionURL(&object, autheliaURL)) + } + + return + } + + authn.Object = object + authn.Method = friendlyMethod(authn.Object.Method) + + ruleHasSubject, required := ctx.Providers.Authorizer.GetRequiredLevel( + authorization.Subject{ + Username: authn.Details.Username, + Groups: authn.Details.Groups, + IP: ctx.RemoteIP(), + }, + object, + ) + + switch isAuthzResult(authn.Level, required, ruleHasSubject) { + case AuthzResultForbidden: + ctx.Logger.Infof("Access to '%s' is forbidden to user '%s'", object.URL.String(), authn.Username) + ctx.ReplyForbidden() + case AuthzResultUnauthorized: + var handler HandlerAuthzUnauthorized + + if strategy != nil { + handler = strategy.HandleUnauthorized + } else { + handler = authz.handleUnauthorized + } + + handler(ctx, &authn, authz.getRedirectionURL(&object, autheliaURL)) + case AuthzResultAuthorized: + authz.handleAuthorized(ctx, &authn) + } +} + +func (authz *Authz) getAutheliaURL(ctx *middlewares.AutheliaCtx, provider *session.Session) (autheliaURL *url.URL, err error) { + if authz.handleGetAutheliaURL == nil { + return nil, nil + } + + if autheliaURL, err = authz.handleGetAutheliaURL(ctx); err != nil { + return nil, err + } + + if autheliaURL != nil { + return autheliaURL, nil + } + + if provider.Config.AutheliaURL != nil { + if authz.legacy { + return nil, nil + } + + return provider.Config.AutheliaURL, nil + } + + return nil, fmt.Errorf("authelia url lookup failed") +} + +func (authz *Authz) getRedirectionURL(object *authorization.Object, autheliaURL *url.URL) (redirectionURL *url.URL) { + if autheliaURL == nil { + return nil + } + + redirectionURL, _ = url.ParseRequestURI(autheliaURL.String()) + + qry := redirectionURL.Query() + + qry.Set(queryArgRD, object.URL.String()) + + if object.Method != "" { + qry.Set(queryArgRM, object.Method) + } + + redirectionURL.RawQuery = qry.Encode() + + return redirectionURL +} + +func (authz *Authz) authn(ctx *middlewares.AutheliaCtx, provider *session.Session) (authn Authn, strategy AuthnStrategy, err error) { + for _, strategy = range authz.strategies { + if authn, err = strategy.Get(ctx, provider); err != nil { + if strategy.CanHandleUnauthorized() { + return Authn{Type: authn.Type, Level: authentication.NotAuthenticated}, strategy, err + } + + return Authn{Type: authn.Type, Level: authentication.NotAuthenticated}, nil, err + } + + if authn.Level != authentication.NotAuthenticated { + break + } + } + + if strategy.CanHandleUnauthorized() { + return authn, strategy, err + } + + return authn, nil, nil +} diff --git a/internal/handlers/handler_authz_authn.go b/internal/handlers/handler_authz_authn.go new file mode 100644 index 000000000..10188e19e --- /dev/null +++ b/internal/handlers/handler_authz_authn.go @@ -0,0 +1,448 @@ +package handlers + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/utils" +) + +// NewCookieSessionAuthnStrategy creates a new CookieSessionAuthnStrategy. +func NewCookieSessionAuthnStrategy(refreshInterval time.Duration) *CookieSessionAuthnStrategy { + if refreshInterval < time.Second*0 { + return &CookieSessionAuthnStrategy{} + } + + return &CookieSessionAuthnStrategy{ + refreshEnabled: true, + refreshInterval: refreshInterval, + } +} + +// NewHeaderAuthorizationAuthnStrategy creates a new HeaderAuthnStrategy using the Authorization and WWW-Authenticate +// headers, and the 407 Proxy Auth Required response. +func NewHeaderAuthorizationAuthnStrategy() *HeaderAuthnStrategy { + return &HeaderAuthnStrategy{ + authn: AuthnTypeAuthorization, + headerAuthorize: headerAuthorization, + headerAuthenticate: headerWWWAuthenticate, + handleAuthenticate: true, + statusAuthenticate: fasthttp.StatusUnauthorized, + } +} + +// NewHeaderProxyAuthorizationAuthnStrategy creates a new HeaderAuthnStrategy using the Proxy-Authorization and +// Proxy-Authenticate headers, and the 407 Proxy Auth Required response. +func NewHeaderProxyAuthorizationAuthnStrategy() *HeaderAuthnStrategy { + return &HeaderAuthnStrategy{ + authn: AuthnTypeProxyAuthorization, + headerAuthorize: headerProxyAuthorization, + headerAuthenticate: headerProxyAuthenticate, + handleAuthenticate: true, + statusAuthenticate: fasthttp.StatusProxyAuthRequired, + } +} + +// NewHeaderProxyAuthorizationAuthRequestAuthnStrategy creates a new HeaderAuthnStrategy using the Proxy-Authorization +// and WWW-Authenticate headers, and the 401 Proxy Auth Required response. This is a special AuthnStrategy for the +// AuthRequest implementation. +func NewHeaderProxyAuthorizationAuthRequestAuthnStrategy() *HeaderAuthnStrategy { + return &HeaderAuthnStrategy{ + authn: AuthnTypeProxyAuthorization, + headerAuthorize: headerProxyAuthorization, + headerAuthenticate: headerWWWAuthenticate, + handleAuthenticate: true, + statusAuthenticate: fasthttp.StatusUnauthorized, + } +} + +// NewHeaderLegacyAuthnStrategy creates a new HeaderLegacyAuthnStrategy. +func NewHeaderLegacyAuthnStrategy() *HeaderLegacyAuthnStrategy { + return &HeaderLegacyAuthnStrategy{} +} + +// CookieSessionAuthnStrategy is a session cookie AuthnStrategy. +type CookieSessionAuthnStrategy struct { + refreshEnabled bool + refreshInterval time.Duration +} + +// Get returns the Authn information for this AuthnStrategy. +func (s *CookieSessionAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, provider *session.Session) (authn Authn, err error) { + authn = Authn{ + Type: AuthnTypeCookie, + Level: authentication.NotAuthenticated, + } + + var userSession session.UserSession + + if userSession, err = provider.GetSession(ctx.RequestCtx); err != nil { + return authn, fmt.Errorf("failed to retrieve user session: %w", err) + } + + if userSession.CookieDomain != provider.Config.Domain { + ctx.Logger.Warnf("Destroying session cookie as the cookie domain '%s' does not match the requests detected cookie domain '%s' which may be a sign a user tried to move this cookie from one domain to another", userSession.CookieDomain, provider.Config.Domain) + + if err = provider.DestroySession(ctx.RequestCtx); err != nil { + ctx.Logger.WithError(err).Error("Error occurred trying to destroy the session cookie") + } + + userSession = provider.NewDefaultUserSession() + + if err = provider.SaveSession(ctx.RequestCtx, userSession); err != nil { + ctx.Logger.WithError(err).Error("Error occurred trying to save the new session cookie") + } + } + + if invalid := handleVerifyGETAuthnCookieValidate(ctx, provider, &userSession, s.refreshEnabled, s.refreshInterval); invalid { + if err = ctx.DestroySession(); err != nil { + ctx.Logger.Errorf("Unable to destroy user session: %+v", err) + } + + userSession = provider.NewDefaultUserSession() + userSession.LastActivity = ctx.Clock.Now().Unix() + + if err = provider.SaveSession(ctx.RequestCtx, userSession); err != nil { + ctx.Logger.Errorf("Unable to save updated user session: %+v", err) + } + + return authn, nil + } + + if err = provider.SaveSession(ctx.RequestCtx, userSession); err != nil { + ctx.Logger.Errorf("Unable to save updated user session: %+v", err) + } + + return Authn{ + Username: friendlyUsername(userSession.Username), + Details: authentication.UserDetails{ + Username: userSession.Username, + DisplayName: userSession.DisplayName, + Emails: userSession.Emails, + Groups: userSession.Groups, + }, + Level: userSession.AuthenticationLevel, + Type: AuthnTypeCookie, + }, nil +} + +// CanHandleUnauthorized returns true if this AuthnStrategy should handle Unauthorized requests. +func (s *CookieSessionAuthnStrategy) CanHandleUnauthorized() (handle bool) { + return false +} + +// HandleUnauthorized is the Unauthorized handler for the cookie AuthnStrategy. +func (s *CookieSessionAuthnStrategy) HandleUnauthorized(_ *middlewares.AutheliaCtx, _ *Authn, _ *url.URL) { +} + +// HeaderAuthnStrategy is a header AuthnStrategy. +type HeaderAuthnStrategy struct { + authn AuthnType + headerAuthorize []byte + headerAuthenticate []byte + handleAuthenticate bool + statusAuthenticate int +} + +// Get returns the Authn information for this AuthnStrategy. +func (s *HeaderAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, _ *session.Session) (authn Authn, err error) { + var ( + username, password string + value []byte + ) + + authn = Authn{ + Type: s.authn, + Level: authentication.NotAuthenticated, + } + + if value = ctx.Request.Header.PeekBytes(s.headerAuthorize); value == nil { + return authn, nil + } + + if username, password, err = headerAuthorizationParse(value); err != nil { + return authn, fmt.Errorf("failed to parse content of %s header: %w", s.headerAuthorize, err) + } + + if username == "" || password == "" { + return authn, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s': %w", s.headerAuthorize, username, err) + } + + var ( + valid bool + details *authentication.UserDetails + ) + + if valid, err = ctx.Providers.UserProvider.CheckUserPassword(username, password); err != nil { + return authn, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s': %w", s.headerAuthorize, username, err) + } + + if !valid { + return authn, fmt.Errorf("validated parsed credentials of %s header but they are not valid for user '%s': %w", s.headerAuthorize, username, err) + } + + if details, err = ctx.Providers.UserProvider.GetDetails(username); err != nil { + if errors.Is(err, authentication.ErrUserNotFound) { + ctx.Logger.Errorf("Error occurred while attempting to get user details for user '%s': the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login", username) + + return authn, err + } + + return authn, fmt.Errorf("unable to retrieve details for user '%s': %w", username, err) + } + + authn.Username = friendlyUsername(details.Username) + authn.Details = *details + authn.Level = authentication.OneFactor + + return authn, nil +} + +// CanHandleUnauthorized returns true if this AuthnStrategy should handle Unauthorized requests. +func (s *HeaderAuthnStrategy) CanHandleUnauthorized() (handle bool) { + return s.handleAuthenticate +} + +// HandleUnauthorized is the Unauthorized handler for the header AuthnStrategy. +func (s *HeaderAuthnStrategy) HandleUnauthorized(ctx *middlewares.AutheliaCtx, _ *Authn, _ *url.URL) { + ctx.Logger.Debugf("Responding %d %s", s.statusAuthenticate, s.headerAuthenticate) + + ctx.ReplyStatusCode(s.statusAuthenticate) + + if s.headerAuthenticate != nil { + ctx.Response.Header.SetBytesKV(s.headerAuthenticate, headerValueAuthenticateBasic) + } +} + +// HeaderLegacyAuthnStrategy is a legacy header AuthnStrategy which can be switched based on the query parameters. +type HeaderLegacyAuthnStrategy struct{} + +// Get returns the Authn information for this AuthnStrategy. +func (s *HeaderLegacyAuthnStrategy) Get(ctx *middlewares.AutheliaCtx, _ *session.Session) (authn Authn, err error) { + var ( + username, password string + value, header []byte + ) + + authn = Authn{ + Level: authentication.NotAuthenticated, + } + + if qryValueAuth := ctx.QueryArgs().PeekBytes(qryArgAuth); bytes.Equal(qryValueAuth, qryValueBasic) { + authn.Type = AuthnTypeAuthorization + header = headerAuthorization + } else { + authn.Type = AuthnTypeProxyAuthorization + header = headerProxyAuthorization + } + + value = ctx.Request.Header.PeekBytes(header) + + switch { + case value == nil && authn.Type == AuthnTypeAuthorization: + return authn, fmt.Errorf("header %s expected", headerAuthorization) + case value == nil: + return authn, nil + } + + if username, password, err = headerAuthorizationParse(value); err != nil { + return authn, fmt.Errorf("failed to parse content of %s header: %w", header, err) + } + + if username == "" || password == "" { + return authn, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s': %w", header, username, err) + } + + var ( + valid bool + details *authentication.UserDetails + ) + + if valid, err = ctx.Providers.UserProvider.CheckUserPassword(username, password); err != nil { + return authn, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s': %w", header, username, err) + } + + if !valid { + return authn, fmt.Errorf("validated parsed credentials of %s header but they are not valid for user '%s': %w", header, username, err) + } + + if details, err = ctx.Providers.UserProvider.GetDetails(username); err != nil { + if errors.Is(err, authentication.ErrUserNotFound) { + ctx.Logger.Errorf("Error occurred while attempting to get user details for user '%s': the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login", username) + + return authn, err + } + + return authn, fmt.Errorf("unable to retrieve details for user '%s': %w", username, err) + } + + authn.Username = friendlyUsername(details.Username) + authn.Details = *details + authn.Level = authentication.OneFactor + + return authn, nil +} + +// CanHandleUnauthorized returns true if this AuthnStrategy should handle Unauthorized requests. +func (s *HeaderLegacyAuthnStrategy) CanHandleUnauthorized() (handle bool) { + return true +} + +// HandleUnauthorized is the Unauthorized handler for the Legacy header AuthnStrategy. +func (s *HeaderLegacyAuthnStrategy) HandleUnauthorized(ctx *middlewares.AutheliaCtx, authn *Authn, _ *url.URL) { + handleAuthzUnauthorizedAuthorizationBasic(ctx, authn) +} + +func handleVerifyGETAuthnCookieValidate(ctx *middlewares.AutheliaCtx, provider *session.Session, userSession *session.UserSession, profileRefreshEnabled bool, profileRefreshInterval time.Duration) (invalid bool) { + isAnonymous := userSession.Username == "" + + if isAnonymous && userSession.AuthenticationLevel != authentication.NotAuthenticated { + ctx.Logger.Errorf("Session for anonymous user has an authentication level of '%s': this may be a sign of a compromise", userSession.AuthenticationLevel) + + return true + } + + if invalid = handleVerifyGETAuthnCookieValidateInactivity(ctx, provider, userSession, isAnonymous); invalid { + ctx.Logger.Infof("Session for user '%s' not marked as remembereded has exceeded configured session inactivity", userSession.Username) + + return true + } + + if invalid = handleVerifyGETAuthnCookieValidateUpdate(ctx, userSession, isAnonymous, profileRefreshEnabled, profileRefreshInterval); invalid { + return true + } + + if username := ctx.Request.Header.PeekBytes(headerSessionUsername); username != nil && !strings.EqualFold(string(username), userSession.Username) { + ctx.Logger.Warnf("Session for user '%s' does not match the Session-Username header with value '%s' which could be a sign of a cookie hijack", userSession.Username, username) + + return true + } + + if !userSession.KeepMeLoggedIn { + userSession.LastActivity = ctx.Clock.Now().Unix() + } + + return false +} + +func handleVerifyGETAuthnCookieValidateInactivity(ctx *middlewares.AutheliaCtx, provider *session.Session, userSession *session.UserSession, isAnonymous bool) (invalid bool) { + if isAnonymous || userSession.KeepMeLoggedIn || int64(provider.Config.Inactivity.Seconds()) == 0 { + return false + } + + ctx.Logger.Tracef("Inactivity report for user '%s'. Current Time: %d, Last Activity: %d, Maximum Inactivity: %d.", userSession.Username, ctx.Clock.Now().Unix(), userSession.LastActivity, int(provider.Config.Inactivity.Seconds())) + + return time.Unix(userSession.LastActivity, 0).Add(provider.Config.Inactivity).Before(ctx.Clock.Now()) +} + +func handleVerifyGETAuthnCookieValidateUpdate(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, isAnonymous, enabled bool, interval time.Duration) (invalid bool) { + if !enabled || isAnonymous { + return false + } + + ctx.Logger.Tracef("Checking if we need check the authentication backend for an updated profile for user '%s'", userSession.Username) + + if interval != schema.RefreshIntervalAlways && userSession.RefreshTTL.After(ctx.Clock.Now()) { + return false + } + + ctx.Logger.Debugf("Checking the authentication backend for an updated profile for user '%s'", userSession.Username) + + var ( + details *authentication.UserDetails + err error + ) + + if details, err = ctx.Providers.UserProvider.GetDetails(userSession.Username); err != nil { + if errors.Is(err, authentication.ErrUserNotFound) { + ctx.Logger.Errorf("Error occurred while attempting to update user details for user '%s': the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login", userSession.Username) + + return true + } + + ctx.Logger.Errorf("Error occurred while attempting to update user details for user '%s': %v", userSession.Username, err) + + return false + } + + var ( + diffEmails, diffGroups, diffDisplayName bool + ) + + diffEmails, diffGroups = utils.IsStringSlicesDifferent(userSession.Emails, details.Emails), utils.IsStringSlicesDifferent(userSession.Groups, details.Groups) + diffDisplayName = userSession.DisplayName != details.DisplayName + + if interval != schema.RefreshIntervalAlways { + userSession.RefreshTTL = ctx.Clock.Now().Add(interval) + } + + if !diffEmails && !diffGroups && !diffDisplayName { + ctx.Logger.Tracef("Updated profile not detected for user '%s'", userSession.Username) + + return false + } + + ctx.Logger.Debugf("Updated profile detected for user '%s'", userSession.Username) + + if ctx.Logger.Level >= logrus.TraceLevel { + generateVerifySessionHasUpToDateProfileTraceLogs(ctx, userSession, details) + } + + userSession.Emails, userSession.Groups, userSession.DisplayName = details.Emails, details.Groups, details.DisplayName + + return false +} + +func headerAuthorizationParse(value []byte) (username, password string, err error) { + if bytes.Equal(value, qryValueEmpty) { + return "", "", fmt.Errorf("header is malformed: empty value") + } + + parts := strings.SplitN(string(value), " ", 2) + + if len(parts) != 2 { + return "", "", fmt.Errorf("header is malformed: does not appear to have a scheme") + } + + scheme := strings.ToLower(parts[0]) + + switch scheme { + case headerAuthorizationSchemeBasic: + if username, password, err = headerAuthorizationParseBasic(parts[1]); err != nil { + return username, password, fmt.Errorf("header is malformed: %w", err) + } + + return username, password, nil + default: + return "", "", fmt.Errorf("header is malformed: unsupported scheme '%s': supported schemes '%s'", parts[0], strings.ToTitle(headerAuthorizationSchemeBasic)) + } +} + +func headerAuthorizationParseBasic(value string) (username, password string, err error) { + var content []byte + + if content, err = base64.StdEncoding.DecodeString(value); err != nil { + return "", "", fmt.Errorf("could not decode credentials: %w", err) + } + + strContent := string(content) + s := strings.IndexByte(strContent, ':') + + if s < 1 { + return "", "", fmt.Errorf("format of header must be : but either doesn't have a colon or username") + } + + return strContent[:s], strContent[s+1:], nil +} diff --git a/internal/handlers/handler_authz_builder.go b/internal/handlers/handler_authz_builder.go new file mode 100644 index 000000000..98aa39215 --- /dev/null +++ b/internal/handlers/handler_authz_builder.go @@ -0,0 +1,190 @@ +package handlers + +import ( + "fmt" + "time" + + "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/utils" +) + +// NewAuthzBuilder creates a new AuthzBuilder. +func NewAuthzBuilder() *AuthzBuilder { + return &AuthzBuilder{ + config: AuthzConfig{RefreshInterval: time.Second * -1}, + } +} + +// WithStrategies replaces all strategies in this builder with the provided value. +func (b *AuthzBuilder) WithStrategies(strategies ...AuthnStrategy) *AuthzBuilder { + b.strategies = strategies + + return b +} + +// WithStrategyCookie adds the Cookie header strategy to the strategies in this builder. +func (b *AuthzBuilder) WithStrategyCookie(refreshInterval time.Duration) *AuthzBuilder { + b.strategies = append(b.strategies, NewCookieSessionAuthnStrategy(refreshInterval)) + + return b +} + +// WithStrategyAuthorization adds the Authorization header strategy to the strategies in this builder. +func (b *AuthzBuilder) WithStrategyAuthorization() *AuthzBuilder { + b.strategies = append(b.strategies, NewHeaderAuthorizationAuthnStrategy()) + + return b +} + +// WithStrategyProxyAuthorization adds the Proxy-Authorization header strategy to the strategies in this builder. +func (b *AuthzBuilder) WithStrategyProxyAuthorization() *AuthzBuilder { + b.strategies = append(b.strategies, NewHeaderProxyAuthorizationAuthnStrategy()) + + return b +} + +// WithImplementationLegacy configures this builder to output an Authz which is used with the Legacy +// implementation which is a mix of the other implementations and usually works with most proxies. +func (b *AuthzBuilder) WithImplementationLegacy() *AuthzBuilder { + b.impl = AuthzImplLegacy + + return b +} + +// WithImplementationForwardAuth configures this builder to output an Authz which is used with the ForwardAuth +// implementation traditionally used by Traefik, Caddy, and Skipper. +func (b *AuthzBuilder) WithImplementationForwardAuth() *AuthzBuilder { + b.impl = AuthzImplForwardAuth + + return b +} + +// WithImplementationAuthRequest configures this builder to output an Authz which is used with the AuthRequest +// implementation traditionally used by NGINX. +func (b *AuthzBuilder) WithImplementationAuthRequest() *AuthzBuilder { + b.impl = AuthzImplAuthRequest + + return b +} + +// WithImplementationExtAuthz configures this builder to output an Authz which is used with the ExtAuthz +// implementation traditionally used by Envoy. +func (b *AuthzBuilder) WithImplementationExtAuthz() *AuthzBuilder { + b.impl = AuthzImplExtAuthz + + return b +} + +// WithConfig allows configuring the Authz config by providing a *schema.Configuration. This function converts it to +// an AuthzConfig and assigns it to the builder. +func (b *AuthzBuilder) WithConfig(config *schema.Configuration) *AuthzBuilder { + if config == nil { + return b + } + + var refreshInterval time.Duration + + switch config.AuthenticationBackend.RefreshInterval { + case schema.ProfileRefreshDisabled: + refreshInterval = time.Second * -1 + case schema.ProfileRefreshAlways: + refreshInterval = time.Second * 0 + default: + refreshInterval, _ = utils.ParseDurationString(config.AuthenticationBackend.RefreshInterval) + } + + b.config = AuthzConfig{ + RefreshInterval: refreshInterval, + Domains: []AuthzDomain{ + { + Name: fmt.Sprintf(".%s", config.Session.Domain), + PortalURL: nil, + }, + }, + } + + return b +} + +// WithEndpointConfig configures the AuthzBuilder with a *schema.ServerAuthzEndpointConfig. Should be called AFTER +// WithConfig or WithAuthzConfig. +func (b *AuthzBuilder) WithEndpointConfig(config schema.ServerAuthzEndpoint) *AuthzBuilder { + switch config.Implementation { + case AuthzImplForwardAuth.String(): + b.WithImplementationForwardAuth() + case AuthzImplAuthRequest.String(): + b.WithImplementationAuthRequest() + case AuthzImplExtAuthz.String(): + b.WithImplementationExtAuthz() + default: + b.WithImplementationLegacy() + } + + b.WithStrategies() + + for _, strategy := range config.AuthnStrategies { + switch strategy.Name { + case AuthnStrategyCookieSession: + b.strategies = append(b.strategies, NewCookieSessionAuthnStrategy(b.config.RefreshInterval)) + case AuthnStrategyHeaderAuthorization: + b.strategies = append(b.strategies, NewHeaderAuthorizationAuthnStrategy()) + case AuthnStrategyHeaderProxyAuthorization: + b.strategies = append(b.strategies, NewHeaderProxyAuthorizationAuthnStrategy()) + case AuthnStrategyHeaderAuthRequestProxyAuthorization: + b.strategies = append(b.strategies, NewHeaderProxyAuthorizationAuthRequestAuthnStrategy()) + case AuthnStrategyHeaderLegacy: + b.strategies = append(b.strategies, NewHeaderLegacyAuthnStrategy()) + } + } + + return b +} + +// WithAuthzConfig allows configuring the Authz config by providing a AuthzConfig directly. Recommended this is only +// used in testing and WithConfig is used instead. +func (b *AuthzBuilder) WithAuthzConfig(config AuthzConfig) *AuthzBuilder { + b.config = config + + return b +} + +// Build returns a new Authz from the currently configured options in this builder. +func (b *AuthzBuilder) Build() (authz *Authz) { + authz = &Authz{ + config: b.config, + strategies: b.strategies, + handleAuthorized: handleAuthzAuthorizedStandard, + } + + if len(authz.strategies) == 0 { + switch b.impl { + case AuthzImplLegacy: + authz.strategies = []AuthnStrategy{NewHeaderLegacyAuthnStrategy(), NewCookieSessionAuthnStrategy(b.config.RefreshInterval)} + case AuthzImplAuthRequest: + authz.strategies = []AuthnStrategy{NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), NewCookieSessionAuthnStrategy(b.config.RefreshInterval)} + default: + authz.strategies = []AuthnStrategy{NewHeaderProxyAuthorizationAuthnStrategy(), NewCookieSessionAuthnStrategy(b.config.RefreshInterval)} + } + } + + switch b.impl { + case AuthzImplLegacy: + authz.legacy = true + authz.handleGetObject = handleAuthzGetObjectLegacy + authz.handleUnauthorized = handleAuthzUnauthorizedLegacy + authz.handleGetAutheliaURL = handleAuthzPortalURLLegacy + case AuthzImplForwardAuth: + authz.handleGetObject = handleAuthzGetObjectForwardAuth + authz.handleUnauthorized = handleAuthzUnauthorizedForwardAuth + authz.handleGetAutheliaURL = handleAuthzPortalURLFromQuery + case AuthzImplAuthRequest: + authz.handleGetObject = handleAuthzGetObjectAuthRequest + authz.handleUnauthorized = handleAuthzUnauthorizedAuthRequest + case AuthzImplExtAuthz: + authz.handleGetObject = handleAuthzGetObjectExtAuthz + authz.handleUnauthorized = handleAuthzUnauthorizedExtAuthz + authz.handleGetAutheliaURL = handleAuthzPortalURLFromHeader + } + + return authz +} diff --git a/internal/handlers/handler_authz_common.go b/internal/handlers/handler_authz_common.go new file mode 100644 index 000000000..e25bea544 --- /dev/null +++ b/internal/handlers/handler_authz_common.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "fmt" + "net/url" + "strings" + + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/utils" +) + +func handleAuthzPortalURLLegacy(ctx *middlewares.AutheliaCtx) (portalURL *url.URL, err error) { + if portalURL, err = handleAuthzPortalURLFromQueryLegacy(ctx); err != nil || portalURL != nil { + return portalURL, err + } + + return handleAuthzPortalURLFromHeader(ctx) +} + +func handleAuthzPortalURLFromHeader(ctx *middlewares.AutheliaCtx) (portalURL *url.URL, err error) { + rawURL := ctx.XAutheliaURL() + if rawURL == nil { + return nil, nil + } + + if portalURL, err = url.ParseRequestURI(string(rawURL)); err != nil { + return nil, err + } + + return portalURL, nil +} + +func handleAuthzPortalURLFromQuery(ctx *middlewares.AutheliaCtx) (portalURL *url.URL, err error) { + rawURL := ctx.QueryArgAutheliaURL() + if rawURL == nil { + return nil, nil + } + + if portalURL, err = url.ParseRequestURI(string(rawURL)); err != nil { + return nil, err + } + + return portalURL, nil +} + +func handleAuthzPortalURLFromQueryLegacy(ctx *middlewares.AutheliaCtx) (portalURL *url.URL, err error) { + rawURL := ctx.QueryArgs().PeekBytes(qryArgRD) + if rawURL == nil { + return nil, nil + } + + if portalURL, err = url.ParseRequestURI(string(rawURL)); err != nil { + return nil, err + } + + return portalURL, nil +} + +func handleAuthzAuthorizedStandard(ctx *middlewares.AutheliaCtx, authn *Authn) { + ctx.ReplyStatusCode(fasthttp.StatusOK) + + if authn.Details.Username != "" { + ctx.Response.Header.SetBytesK(headerRemoteUser, authn.Details.Username) + ctx.Response.Header.SetBytesK(headerRemoteGroups, strings.Join(authn.Details.Groups, ",")) + ctx.Response.Header.SetBytesK(headerRemoteName, authn.Details.DisplayName) + + switch len(authn.Details.Emails) { + case 0: + ctx.Response.Header.SetBytesK(headerRemoteEmail, "") + default: + ctx.Response.Header.SetBytesK(headerRemoteEmail, authn.Details.Emails[0]) + } + } +} + +func handleAuthzUnauthorizedAuthorizationBasic(ctx *middlewares.AutheliaCtx, authn *Authn) { + ctx.Logger.Infof("Access to '%s' is not authorized to user '%s', sending 401 response with WWW-Authenticate header requesting Basic scheme", authn.Object.URL.String(), authn.Username) + + ctx.ReplyUnauthorized() + + ctx.Response.Header.SetBytesKV(headerWWWAuthenticate, headerValueAuthenticateBasic) +} + +var protoHostSeparator = []byte("://") + +func getRequestURIFromForwardedHeaders(protocol, host, uri []byte) (requestURI *url.URL, err error) { + if len(protocol) == 0 { + return nil, fmt.Errorf("missing protocol value") + } + + if len(host) == 0 { + return nil, fmt.Errorf("missing host value") + } + + value := utils.BytesJoin(protocol, protoHostSeparator, host, uri) + + if requestURI, err = url.ParseRequestURI(string(value)); err != nil { + return nil, fmt.Errorf("failed to parse forwarded headers: %w", err) + } + + return requestURI, nil +} + +func hasInvalidMethodCharacters(v []byte) bool { + for _, c := range v { + if c < 0x41 || c > 0x5A { + return true + } + } + + return false +} diff --git a/internal/handlers/handler_authz_impl_authrequest.go b/internal/handlers/handler_authz_impl_authrequest.go new file mode 100644 index 000000000..19292201f --- /dev/null +++ b/internal/handlers/handler_authz_impl_authrequest.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "fmt" + "net/url" + + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" +) + +func handleAuthzGetObjectAuthRequest(ctx *middlewares.AutheliaCtx) (object authorization.Object, err error) { + var ( + targetURL *url.URL + + rawURL, method []byte + ) + + if rawURL = ctx.XOriginalURL(); len(rawURL) == 0 { + return object, middlewares.ErrMissingXOriginalURL + } + + if targetURL, err = url.ParseRequestURI(string(rawURL)); err != nil { + return object, fmt.Errorf("failed to parse X-Original-URL header: %w", err) + } + + if method = ctx.XOriginalMethod(); len(method) == 0 { + return object, fmt.Errorf("header 'X-Original-Method' is empty") + } + + if hasInvalidMethodCharacters(method) { + return object, fmt.Errorf("header 'X-Original-Method' with value '%s' has invalid characters", method) + } + + return authorization.NewObjectRaw(targetURL, method), nil +} + +func handleAuthzUnauthorizedAuthRequest(ctx *middlewares.AutheliaCtx, authn *Authn, _ *url.URL) { + ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, responding with status code %d", authn.Object.URL.String(), authn.Method, authn.Username, fasthttp.StatusUnauthorized) + ctx.ReplyUnauthorized() +} diff --git a/internal/handlers/handler_authz_impl_authrequest_test.go b/internal/handlers/handler_authz_impl_authrequest_test.go new file mode 100644 index 000000000..6f4a1d8b4 --- /dev/null +++ b/internal/handlers/handler_authz_impl_authrequest_test.go @@ -0,0 +1,456 @@ +package handlers + +import ( + "fmt" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/mocks" + "github.com/authelia/authelia/v4/internal/session" +) + +func TestRunAuthRequestAuthzSuite(t *testing.T) { + suite.Run(t, NewAuthRequestAuthzSuite()) +} + +func NewAuthRequestAuthzSuite() *AuthRequestAuthzSuite { + return &AuthRequestAuthzSuite{ + AuthzSuite: &AuthzSuite{ + implementation: AuthzImplAuthRequest, + setRequest: setRequestAuthRequest, + }, + } +} + +type AuthRequestAuthzSuite struct { + *AuthzSuite +} + +func (s *AuthRequestAuthzSuite) TestShouldHandleAllMethodsDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("OriginalMethod%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://one-factor.example.com"), + s.RequireParseRequestURI("https://one-factor.example.com/subpath"), + s.RequireParseRequestURI("https://one-factor.example2.com"), + s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { + for _, method := range testRequestMethods { + method += "z" + + s.T().Run(fmt.Sprintf("OriginalMethod%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldHandleMissingXOriginalMethodDeny() { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + s.T().Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + s.setRequest(mock.Ctx, "", targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldHandleMissingXOriginalURLDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("OriginalMethod%s", method), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + s.setRequest(mock.Ctx, method, nil, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldHandleAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("OriginalMethod%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldHandleAllMethodsWithMethodsACL() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, methodACL := range testRequestMethods { + targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + if method == methodACL { + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + } else { + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + } + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldHandleInvalidURLForCVE202132637() { + testCases := []struct { + name string + uri []byte + expected int + }{ + {"Should401UnauthorizedWithNullByte", + []byte{104, 116, 116, 112, 115, 58, 47, 47, 0, 110, 111, 116, 45, 111, 110, 101, 45, 102, 97, 99, 116, 111, 114, 46, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109}, + fasthttp.StatusUnauthorized, + }, + {"Should200OkWithoutNullByte", + []byte{104, 116, 116, 112, 115, 58, 47, 47, 110, 111, 116, 45, 111, 110, 101, 45, 102, 97, 99, 116, 111, 114, 46, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109}, + fasthttp.StatusOK, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + for _, method := range testRequestMethods { + t.Run(fmt.Sprintf("OriginalMethod%s", method), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + mock.Ctx.Configuration.AccessControl.DefaultPolicy = testBypass + mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&mock.Ctx.Configuration) + + mock.Ctx.Request.Header.Set(testXOriginalMethod, method) + mock.Ctx.Request.Header.SetBytesKV([]byte(testXOriginalUrl), tc.uri) + + authz.Handler(mock.Ctx) + + assert.Equal(t, tc.expected, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestExtAuthz(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsAllowXHR() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for xname, x := range testXHR { + t.Run(xname, func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestExtAuthz(mock.Ctx, method, targetURI, x, x) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsWithMethodsACL() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, methodACL := range testRequestMethods { + targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestExtAuthz(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestForwardAuth(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsAllowXHR() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for xname, x := range testXHR { + t.Run(xname, func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestForwardAuth(mock.Ctx, method, targetURI, x, x) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } + }) + } +} + +func (s *AuthRequestAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsWithMethodsACL() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, methodACL := range testRequestMethods { + targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestForwardAuth(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func setRequestAuthRequest(ctx *middlewares.AutheliaCtx, method string, targetURI *url.URL, accept, xhr bool) { + if method != "" { + ctx.Request.Header.Set(testXOriginalMethod, method) + } + + if targetURI != nil { + ctx.Request.Header.Set(testXOriginalUrl, targetURI.String()) + } + + setRequestXHRValues(ctx, accept, xhr) +} diff --git a/internal/handlers/handler_authz_impl_extauthz.go b/internal/handlers/handler_authz_impl_extauthz.go new file mode 100644 index 000000000..ccae832d9 --- /dev/null +++ b/internal/handlers/handler_authz_impl_extauthz.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "fmt" + "net/url" + + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" +) + +func handleAuthzGetObjectExtAuthz(ctx *middlewares.AutheliaCtx) (object authorization.Object, err error) { + protocol, host, uri := ctx.XForwardedProto(), ctx.RequestCtx.Host(), ctx.AuthzPath() + + var ( + targetURL *url.URL + method []byte + ) + + if targetURL, err = getRequestURIFromForwardedHeaders(protocol, host, uri); err != nil { + return object, fmt.Errorf("failed to get target URL: %w", err) + } + + if method = ctx.Method(); len(method) == 0 { + return object, fmt.Errorf("start line value 'Method' is empty") + } + + if hasInvalidMethodCharacters(method) { + return object, fmt.Errorf("start line value 'Method' with value '%s' has invalid characters", method) + } + + return authorization.NewObjectRaw(targetURL, method), nil +} + +func handleAuthzUnauthorizedExtAuthz(ctx *middlewares.AutheliaCtx, authn *Authn, redirectionURL *url.URL) { + var ( + statusCode int + ) + + switch { + case ctx.IsXHR() || !ctx.AcceptsMIME("text/html"): + statusCode = fasthttp.StatusUnauthorized + default: + switch authn.Object.Method { + case fasthttp.MethodGet, fasthttp.MethodOptions, fasthttp.MethodHead: + statusCode = fasthttp.StatusFound + default: + statusCode = fasthttp.StatusSeeOther + } + } + + ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, responding with status code %d with location redirect to %s", authn.Object.String(), authn.Method, authn.Username, statusCode, redirectionURL) + ctx.SpecialRedirect(redirectionURL.String(), statusCode) +} diff --git a/internal/handlers/handler_authz_impl_extauthz_test.go b/internal/handlers/handler_authz_impl_extauthz_test.go new file mode 100644 index 000000000..820cb66d1 --- /dev/null +++ b/internal/handlers/handler_authz_impl_extauthz_test.go @@ -0,0 +1,615 @@ +package handlers + +import ( + "fmt" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/mocks" + "github.com/authelia/authelia/v4/internal/session" +) + +func TestRunExtAuthzAuthzSuite(t *testing.T) { + suite.Run(t, NewExtAuthzAuthzSuite()) +} + +func NewExtAuthzAuthzSuite() *ExtAuthzAuthzSuite { + return &ExtAuthzAuthzSuite{ + AuthzSuite: &AuthzSuite{ + implementation: AuthzImplExtAuthz, + setRequest: setRequestExtAuthz, + }, + } +} + +type ExtAuthzAuthzSuite struct { + *AuthzSuite +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, pairURI := range []urlpair{ + {s.RequireParseRequestURI("https://one-factor.example.com"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example.com/subpath"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com"), s.RequireParseRequestURI("https://auth.example2.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), s.RequireParseRequestURI("https://auth.example2.com/")}, + } { + t.Run(pairURI.TargetURI.String(), func(t *testing.T) { + expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, pairURI.TargetURI, true, false) + + authz.Handler(mock.Ctx) + + switch method { + case fasthttp.MethodGet, fasthttp.MethodOptions, fasthttp.MethodHead: + assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + assert.Equal(t, fasthttp.StatusSeeOther, mock.Ctx.Response.StatusCode()) + } + + query := expected.Query() + query.Set(queryArgRD, pairURI.TargetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsOverrideAutheliaURLDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, pairURI := range []urlpair{ + {s.RequireParseRequestURI("https://one-factor.example.com"), s.RequireParseRequestURI("https://auth-from-override.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example.com/subpath"), s.RequireParseRequestURI("https://auth-from-override.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com"), s.RequireParseRequestURI("https://auth-from-override.example2.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), s.RequireParseRequestURI("https://auth-from-override.example2.com/")}, + } { + t.Run(pairURI.TargetURI.String(), func(t *testing.T) { + expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.Request.Header.Set("X-Authelia-Url", pairURI.AutheliaURI.String()) + s.setRequest(mock.Ctx, method, pairURI.TargetURI, true, false) + + authz.Handler(mock.Ctx) + + switch method { + case fasthttp.MethodGet, fasthttp.MethodOptions, fasthttp.MethodHead: + assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + assert.Equal(t, fasthttp.StatusSeeOther, mock.Ctx.Response.StatusCode()) + } + + query := expected.Query() + query.Set(queryArgRD, pairURI.TargetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsMissingAutheliaURLDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, "", string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsXHRDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for xname, x := range testXHR { + t.Run(xname, func(t *testing.T) { + for _, pairURI := range []urlpair{ + {s.RequireParseRequestURI("https://one-factor.example.com"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example.com/subpath"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com"), s.RequireParseRequestURI("https://auth.example2.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), s.RequireParseRequestURI("https://auth.example2.com/")}, + } { + t.Run(pairURI.TargetURI.String(), func(t *testing.T) { + expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, pairURI.TargetURI, x, x) + + mock.Ctx.SetUserValue("authz_path", pairURI.TargetURI.Path) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + + query := expected.Query() + query.Set(queryArgRD, pairURI.TargetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { + for _, method := range testRequestMethods { + method += "z" + + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleMissingHostDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, nil, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsAllowXHR() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for xname, x := range testXHR { + t.Run(xname, func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, targetURI, x, x) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsWithMethodsACL() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, methodACL := range testRequestMethods { + targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + if method == methodACL { + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + } else { + expected := s.RequireParseRequestURI("https://auth.example.com/") + + switch method { + case fasthttp.MethodGet, fasthttp.MethodOptions, fasthttp.MethodHead: + assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + assert.Equal(t, fasthttp.StatusSeeOther, mock.Ctx.Response.StatusCode()) + } + + query := expected.Query() + query.Set(queryArgRD, targetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + } + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldHandleInvalidURLForCVE202132637() { + testCases := []struct { + name string + scheme, host []byte + path string + expected int + }{ + {"Should401UnauthorizedWithNullByte", + []byte("https"), []byte{0, 110, 111, 116, 45, 111, 110, 101, 45, 102, 97, 99, 116, 111, 114, 46, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109}, "/path-example", + fasthttp.StatusUnauthorized, + }, + {"Should200OkWithoutNullByte", + []byte("https"), []byte{110, 111, 116, 45, 111, 110, 101, 45, 102, 97, 99, 116, 111, 114, 46, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109}, "/path-example", + fasthttp.StatusOK, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + for _, method := range testRequestMethods { + t.Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + mock.Ctx.Configuration.AccessControl.DefaultPolicy = testBypass + mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&mock.Ctx.Configuration) + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.Request.SetHostBytes(tc.host) + mock.Ctx.Request.Header.SetMethodBytes([]byte(method)) + mock.Ctx.Request.Header.SetBytesKV([]byte(fasthttp.HeaderXForwardedProto), tc.scheme) + mock.Ctx.Request.Header.Del(fasthttp.HeaderXForwardedHost) + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + mock.Ctx.SetUserValue("authz_path", tc.path) + + authz.Handler(mock.Ctx) + + assert.Equal(t, tc.expected, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldNotHandleAuthRequestAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("OriginalMethod%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + setRequestAuthRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldNotHandleAuthRequestAllMethodsWithMethodsACL() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, methodACL := range testRequestMethods { + targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestAuthRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestForwardAuth(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsAllowXHR() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for xname, x := range testXHR { + t.Run(xname, func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestForwardAuth(mock.Ctx, method, targetURI, x, x) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } + }) + } +} + +func (s *ExtAuthzAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsWithMethodsACL() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, methodACL := range testRequestMethods { + targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestForwardAuth(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func setRequestExtAuthz(ctx *middlewares.AutheliaCtx, method string, targetURI *url.URL, accept, xhr bool) { + ctx.Request.Header.Del(fasthttp.HeaderXForwardedHost) + + if method != "" { + ctx.Request.Header.SetMethodBytes([]byte(method)) + } + + if targetURI != nil { + ctx.Request.SetHost(targetURI.Host) + ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, targetURI.Scheme) + ctx.SetUserValue("authz_path", targetURI.Path) + } + + setRequestXHRValues(ctx, accept, xhr) +} diff --git a/internal/handlers/handler_authz_impl_forwardauth.go b/internal/handlers/handler_authz_impl_forwardauth.go new file mode 100644 index 000000000..a042c13bb --- /dev/null +++ b/internal/handlers/handler_authz_impl_forwardauth.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "fmt" + "net/url" + + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" +) + +func handleAuthzGetObjectForwardAuth(ctx *middlewares.AutheliaCtx) (object authorization.Object, err error) { + protocol, host, uri := ctx.XForwardedProto(), ctx.XForwardedHost(), ctx.XForwardedURI() + + var ( + targetURL *url.URL + method []byte + ) + + if targetURL, err = getRequestURIFromForwardedHeaders(protocol, host, uri); err != nil { + return object, fmt.Errorf("failed to get target URL: %w", err) + } + + if method = ctx.XForwardedMethod(); len(method) == 0 { + return object, fmt.Errorf("header 'X-Forwarded-Method' is empty") + } + + if hasInvalidMethodCharacters(method) { + return object, fmt.Errorf("header 'X-Forwarded-Method' with value '%s' has invalid characters", method) + } + + return authorization.NewObjectRaw(targetURL, method), nil +} + +func handleAuthzUnauthorizedForwardAuth(ctx *middlewares.AutheliaCtx, authn *Authn, redirectionURL *url.URL) { + var ( + statusCode int + ) + + switch { + case ctx.IsXHR() || !ctx.AcceptsMIME("text/html"): + statusCode = fasthttp.StatusUnauthorized + default: + switch authn.Object.Method { + case fasthttp.MethodGet, fasthttp.MethodOptions, fasthttp.MethodHead: + statusCode = fasthttp.StatusFound + default: + statusCode = fasthttp.StatusSeeOther + } + } + + ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, responding with status code %d with location redirect to %s", authn.Object.String(), authn.Method, authn.Username, statusCode, redirectionURL) + ctx.SpecialRedirect(redirectionURL.String(), statusCode) +} diff --git a/internal/handlers/handler_authz_impl_forwardauth_test.go b/internal/handlers/handler_authz_impl_forwardauth_test.go new file mode 100644 index 000000000..d7ea3baab --- /dev/null +++ b/internal/handlers/handler_authz_impl_forwardauth_test.go @@ -0,0 +1,610 @@ +package handlers + +import ( + "fmt" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/mocks" + "github.com/authelia/authelia/v4/internal/session" +) + +func TestRunForwardAuthAuthzSuite(t *testing.T) { + suite.Run(t, NewForwardAuthAuthzSuite()) +} + +func NewForwardAuthAuthzSuite() *ForwardAuthAuthzSuite { + return &ForwardAuthAuthzSuite{ + AuthzSuite: &AuthzSuite{ + implementation: AuthzImplForwardAuth, + setRequest: setRequestForwardAuth, + }, + } +} + +type ForwardAuthAuthzSuite struct { + *AuthzSuite +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, pairURI := range []urlpair{ + {s.RequireParseRequestURI("https://one-factor.example.com"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example.com/subpath"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com"), s.RequireParseRequestURI("https://auth.example2.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), s.RequireParseRequestURI("https://auth.example2.com/")}, + } { + t.Run(pairURI.TargetURI.String(), func(t *testing.T) { + expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, pairURI.TargetURI, true, false) + + authz.Handler(mock.Ctx) + + switch method { + case fasthttp.MethodGet, fasthttp.MethodOptions, fasthttp.MethodHead: + assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + assert.Equal(t, fasthttp.StatusSeeOther, mock.Ctx.Response.StatusCode()) + } + + query := expected.Query() + query.Set(queryArgRD, pairURI.TargetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsOverrideAutheliaURLDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, pairURI := range []urlpair{ + {s.RequireParseRequestURI("https://one-factor.example.com"), s.RequireParseRequestURI("https://auth-from-override.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example.com/subpath"), s.RequireParseRequestURI("https://auth-from-override.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com"), s.RequireParseRequestURI("https://auth-from-override.example2.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), s.RequireParseRequestURI("https://auth-from-override.example2.com/")}, + } { + t.Run(pairURI.TargetURI.String(), func(t *testing.T) { + expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.RequestCtx.QueryArgs().Set("authelia_url", pairURI.AutheliaURI.String()) + s.setRequest(mock.Ctx, method, pairURI.TargetURI, true, false) + + authz.Handler(mock.Ctx) + + switch method { + case fasthttp.MethodGet, fasthttp.MethodOptions, fasthttp.MethodHead: + assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + assert.Equal(t, fasthttp.StatusSeeOther, mock.Ctx.Response.StatusCode()) + } + + query := expected.Query() + query.Set(queryArgRD, pairURI.TargetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsMissingAutheliaURLDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, "", string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsXHRDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for xname, x := range testXHR { + t.Run(xname, func(t *testing.T) { + for _, pairURI := range []urlpair{ + {s.RequireParseRequestURI("https://one-factor.example.com"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example.com/subpath"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com"), s.RequireParseRequestURI("https://auth.example2.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), s.RequireParseRequestURI("https://auth.example2.com/")}, + } { + t.Run(pairURI.TargetURI.String(), func(t *testing.T) { + expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, pairURI.TargetURI, x, x) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + + query := expected.Query() + query.Set(queryArgRD, pairURI.TargetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { + for _, method := range testRequestMethods { + method += "z" + + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleMissingHostDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, "https") + mock.Ctx.Request.Header.Del(fasthttp.HeaderXForwardedHost) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", "/") + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsWithMethodsACL() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, methodACL := range testRequestMethods { + targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + if method == methodACL { + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + } else { + expected := s.RequireParseRequestURI("https://auth.example.com/") + + switch method { + case fasthttp.MethodGet, fasthttp.MethodOptions, fasthttp.MethodHead: + assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + assert.Equal(t, fasthttp.StatusSeeOther, mock.Ctx.Response.StatusCode()) + } + + query := expected.Query() + query.Set(queryArgRD, targetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + } + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsAllowXHR() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + s.setRequest(mock.Ctx, method, targetURI, true, true) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldHandleInvalidURLForCVE202132637() { + testCases := []struct { + name string + scheme, host []byte + path string + expected int + }{ + {"Should401UnauthorizedWithNullByte", + []byte("https"), []byte{0, 110, 111, 116, 45, 111, 110, 101, 45, 102, 97, 99, 116, 111, 114, 46, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109}, "/path-example", + fasthttp.StatusUnauthorized, + }, + {"Should200OkWithoutNullByte", + []byte("https"), []byte{110, 111, 116, 45, 111, 110, 101, 45, 102, 97, 99, 116, 111, 114, 46, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109}, "/path-example", + fasthttp.StatusOK, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + for _, method := range testRequestMethods { + t.Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + mock.Ctx.Configuration.AccessControl.DefaultPolicy = testBypass + mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&mock.Ctx.Configuration) + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.SetBytesKV([]byte(fasthttp.HeaderXForwardedProto), tc.scheme) + mock.Ctx.Request.Header.SetBytesKV([]byte(fasthttp.HeaderXForwardedHost), tc.host) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", tc.path) + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + assert.Equal(t, tc.expected, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldNotHandleAuthRequestAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("OriginalMethod%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + setRequestAuthRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldNotHandleAuthRequestAllMethodsWithMethodsACL() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, methodACL := range testRequestMethods { + targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestAuthRequest(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestExtAuthz(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsAllowXHR() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for xname, x := range testXHR { + t.Run(xname, func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestExtAuthz(mock.Ctx, method, targetURI, x, x) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } + }) + } +} + +func (s *ForwardAuthAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsWithMethodsACL() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, methodACL := range testRequestMethods { + targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + setRequestExtAuthz(mock.Ctx, method, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func setRequestForwardAuth(ctx *middlewares.AutheliaCtx, method string, targetURI *url.URL, accept, xhr bool) { + if method != "" { + ctx.Request.Header.Set("X-Forwarded-Method", method) + } + + if targetURI != nil { + ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, targetURI.Scheme) + ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, targetURI.Host) + ctx.Request.Header.Set("X-Forwarded-Uri", targetURI.Path) + } + + setRequestXHRValues(ctx, accept, xhr) +} diff --git a/internal/handlers/handler_authz_impl_legacy.go b/internal/handlers/handler_authz_impl_legacy.go new file mode 100644 index 000000000..33af89616 --- /dev/null +++ b/internal/handlers/handler_authz_impl_legacy.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "fmt" + "net/url" + + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" +) + +func handleAuthzGetObjectLegacy(ctx *middlewares.AutheliaCtx) (object authorization.Object, err error) { + var ( + targetURL *url.URL + method []byte + ) + + if targetURL, err = ctx.GetXOriginalURLOrXForwardedURL(); err != nil { + return object, fmt.Errorf("failed to get target URL: %w", err) + } + + if method = ctx.XForwardedMethod(); len(method) == 0 { + method = ctx.Method() + } + + if hasInvalidMethodCharacters(method) { + return object, fmt.Errorf("header 'X-Forwarded-Method' with value '%s' has invalid characters", method) + } + + return authorization.NewObjectRaw(targetURL, method), nil +} + +func handleAuthzUnauthorizedLegacy(ctx *middlewares.AutheliaCtx, authn *Authn, redirectionURL *url.URL) { + var ( + statusCode int + ) + + if authn.Type == AuthnTypeAuthorization { + handleAuthzUnauthorizedAuthorizationBasic(ctx, authn) + + return + } + + switch { + case ctx.IsXHR() || !ctx.AcceptsMIME("text/html") || redirectionURL == nil: + statusCode = fasthttp.StatusUnauthorized + default: + switch authn.Object.Method { + case fasthttp.MethodGet, fasthttp.MethodOptions, "": + statusCode = fasthttp.StatusFound + default: + statusCode = fasthttp.StatusSeeOther + } + } + + if redirectionURL != nil { + ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, responding with status code %d with location redirect to %s", authn.Object.URL.String(), authn.Method, authn.Username, statusCode, redirectionURL.String()) + ctx.SpecialRedirect(redirectionURL.String(), statusCode) + } else { + ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, responding with status code %d", authn.Object.URL.String(), authn.Method, authn.Username, statusCode) + ctx.ReplyUnauthorized() + } +} diff --git a/internal/handlers/handler_authz_impl_legacy_test.go b/internal/handlers/handler_authz_impl_legacy_test.go new file mode 100644 index 000000000..a76936529 --- /dev/null +++ b/internal/handlers/handler_authz_impl_legacy_test.go @@ -0,0 +1,555 @@ +package handlers + +import ( + "fmt" + "net/url" + "regexp" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/mocks" + "github.com/authelia/authelia/v4/internal/session" +) + +func TestRunLegacyAuthzSuite(t *testing.T) { + suite.Run(t, NewLegacyAuthzSuite()) +} + +func NewLegacyAuthzSuite() *LegacyAuthzSuite { + return &LegacyAuthzSuite{ + AuthzSuite: &AuthzSuite{ + implementation: AuthzImplLegacy, + setRequest: setRequestLegacy, + }, + } +} + +type LegacyAuthzSuite struct { + *AuthzSuite +} + +func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, pairURI := range []urlpair{ + {s.RequireParseRequestURI("https://one-factor.example.com/"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example.com/subpath"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/"), s.RequireParseRequestURI("https://auth.example2.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), s.RequireParseRequestURI("https://auth.example2.com/")}, + } { + t.Run(pairURI.TargetURI.String(), func(t *testing.T) { + expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.RequestCtx.QueryArgs().Set(queryArgRD, pairURI.AutheliaURI.String()) + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, pairURI.TargetURI.Scheme) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, pairURI.TargetURI.Host) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", pairURI.TargetURI.Path) + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + switch method { + case fasthttp.MethodGet, fasthttp.MethodOptions: + assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + assert.Equal(t, fasthttp.StatusSeeOther, mock.Ctx.Response.StatusCode()) + } + + query := expected.Query() + query.Set(queryArgRD, pairURI.TargetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } +} + +func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsOverrideAutheliaURLDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, pairURI := range []urlpair{ + {s.RequireParseRequestURI("https://one-factor.example.com/"), s.RequireParseRequestURI("https://auth-from-override.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example.com/subpath"), s.RequireParseRequestURI("https://auth-from-override.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/"), s.RequireParseRequestURI("https://auth-from-override.example2.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), s.RequireParseRequestURI("https://auth-from-override.example2.com/")}, + } { + t.Run(pairURI.TargetURI.String(), func(t *testing.T) { + expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.RequestCtx.QueryArgs().Set(queryArgRD, pairURI.AutheliaURI.String()) + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, pairURI.TargetURI.Scheme) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, pairURI.TargetURI.Host) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", pairURI.TargetURI.Path) + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + switch method { + case fasthttp.MethodGet, fasthttp.MethodOptions: + assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + assert.Equal(t, fasthttp.StatusSeeOther, mock.Ctx.Response.StatusCode()) + } + + query := expected.Query() + query.Set(queryArgRD, pairURI.TargetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } +} + +func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsMissingAutheliaURLDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, targetURI.Scheme) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, targetURI.Host) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", targetURI.Path) + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, "", string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } +} + +func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsXHRDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for xname, x := range testXHR { + t.Run(xname, func(t *testing.T) { + for _, pairURI := range []urlpair{ + {s.RequireParseRequestURI("https://one-factor.example.com/"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example.com/subpath"), s.RequireParseRequestURI("https://auth.example.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/"), s.RequireParseRequestURI("https://auth.example2.com/")}, + {s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), s.RequireParseRequestURI("https://auth.example2.com/")}, + } { + t.Run(pairURI.TargetURI.String(), func(t *testing.T) { + expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.RequestCtx.QueryArgs().Set(queryArgRD, pairURI.AutheliaURI.String()) + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, pairURI.TargetURI.Scheme) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, pairURI.TargetURI.Host) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", pairURI.TargetURI.Path) + + if x { + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + mock.Ctx.Request.Header.Set(fasthttp.HeaderXRequestedWith, "XMLHttpRequest") + } + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + + query := expected.Query() + query.Set(queryArgRD, pairURI.TargetURI.String()) + query.Set(queryArgRM, method) + expected.RawQuery = query.Encode() + + assert.Equal(t, expected.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + }) + } + }) + } + }) + } +} + +func (s *LegacyAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { + for _, method := range testRequestMethods { + method += "z" + + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, targetURI.Scheme) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, targetURI.Host) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", targetURI.Path) + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *LegacyAuthzSuite) TestShouldHandleMissingHostDeny() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, "https") + mock.Ctx.Request.Header.Del(fasthttp.HeaderXForwardedHost) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", "/") + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } +} + +func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsAllow() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, targetURI.Scheme) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, targetURI.Host) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", targetURI.Path) + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsAllowXHR() { + for _, method := range testRequestMethods { + s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + for _, targetURI := range []*url.URL{ + s.RequireParseRequestURI("https://bypass.example.com"), + s.RequireParseRequestURI("https://bypass.example.com/subpath"), + s.RequireParseRequestURI("https://bypass.example2.com"), + s.RequireParseRequestURI("https://bypass.example2.com/subpath"), + } { + t.Run(targetURI.String(), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, targetURI.Scheme) + mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, targetURI.Host) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", targetURI.Path) + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func (s *LegacyAuthzSuite) TestShouldHandleLegacyBasicAuth() { // TestShouldVerifyAuthBasicArgOk. + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.QueryArgs().Add("auth", "basic") + mock.Ctx.Request.Header.Set("Authorization", "Basic am9objpwYXNzd29yZA==") + mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") + + gomock.InOrder( + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil), + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, + }, nil), + ) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) +} + +func (s *LegacyAuthzSuite) TestShouldHandleLegacyBasicAuthFailures() { + testCases := []struct { + name string + setup func(mock *mocks.MockAutheliaCtx) + }{ + { + "HeaderAbsent", // TestShouldVerifyAuthBasicArgFailingNoHeader. + nil, + }, + { + "HeaderEmpty", // TestShouldVerifyAuthBasicArgFailingEmptyHeader. + func(mock *mocks.MockAutheliaCtx) { + mock.Ctx.Request.Header.Set("Authorization", "") + }, + }, + { + "HeaderIncorrect", // TestShouldVerifyAuthBasicArgFailingWrongHeader. + func(mock *mocks.MockAutheliaCtx) { + mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") + }, + }, + { + "IncorrectPassword", // TestShouldVerifyAuthBasicArgFailingWrongPassword. + func(mock *mocks.MockAutheliaCtx) { + mock.Ctx.Request.Header.Set("Authorization", "Basic am9objpwYXNzd29yZA==") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(false, fmt.Errorf("generic error")) + }, + }, + { + "NoAccess", // TestShouldVerifyAuthBasicArgFailingWrongPassword. + func(mock *mocks.MockAutheliaCtx) { + mock.Ctx.Request.Header.Set("Authorization", "Basic am9objpwYXNzd29yZA==") + mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com/") + + gomock.InOrder( + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil), + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admin"}, + }, nil), + ) + }, + }, + } + + authz := s.Builder().Build() + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.QueryArgs().Add("auth", "basic") + mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") + + if tc.setup != nil { + tc.setup(mock) + } + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + assert.Equal(t, "401 Unauthorized", string(mock.Ctx.Response.Body())) + assert.Regexp(t, regexp.MustCompile("^Basic realm="), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + }) + } +} + +func (s *LegacyAuthzSuite) TestShouldHandleInvalidURLForCVE202132637() { + testCases := []struct { + name string + scheme, host []byte + path string + expected int + }{ + // The first byte in the host sequence is the null byte. This should never respond with 200 OK. + {"Should401UnauthorizedWithNullByte", + []byte("https"), []byte{0, 110, 111, 116, 45, 111, 110, 101, 45, 102, 97, 99, 116, 111, 114, 46, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109}, "/path-example", + fasthttp.StatusUnauthorized, + }, + {"Should200OkWithoutNullByte", + []byte("https"), []byte{110, 111, 116, 45, 111, 110, 101, 45, 102, 97, 99, 116, 111, 114, 46, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109}, "/path-example", + fasthttp.StatusOK, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + for _, method := range testRequestMethods { + t.Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + mock.Ctx.Configuration.AccessControl.DefaultPolicy = testBypass + mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&mock.Ctx.Configuration) + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + mock.Ctx.Request.Header.Set("X-Forwarded-Method", method) + mock.Ctx.Request.Header.SetBytesKV([]byte(fasthttp.HeaderXForwardedProto), tc.scheme) + mock.Ctx.Request.Header.SetBytesKV([]byte(fasthttp.HeaderXForwardedHost), tc.host) + mock.Ctx.Request.Header.Set("X-Forwarded-Uri", tc.path) + mock.Ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + + authz.Handler(mock.Ctx) + + assert.Equal(t, tc.expected, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation)) + }) + } + }) + } +} + +func setRequestLegacy(ctx *middlewares.AutheliaCtx, method string, targetURI *url.URL, accept, xhr bool) { + if method != "" { + ctx.Request.Header.Set("X-Forwarded-Method", method) + } + + if targetURI != nil { + ctx.Request.Header.Set(testXOriginalUrl, targetURI.String()) + } + + setRequestXHRValues(ctx, accept, xhr) +} diff --git a/internal/handlers/handler_authz_test.go b/internal/handlers/handler_authz_test.go new file mode 100644 index 000000000..432ee7bbd --- /dev/null +++ b/internal/handlers/handler_authz_test.go @@ -0,0 +1,1573 @@ +package handlers + +import ( + "fmt" + "net/url" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/valyala/fasthttp" + + "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/mocks" + "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/utils" +) + +type AuthzSuite struct { + suite.Suite + + implementation AuthzImplementation + builder *AuthzBuilder + setRequest func(ctx *middlewares.AutheliaCtx, method string, targetURI *url.URL, accept bool, xhr bool) +} + +func (s *AuthzSuite) GetMock(config *schema.Configuration, targetURI *url.URL, session *session.UserSession) *mocks.MockAutheliaCtx { + mock := mocks.NewMockAutheliaCtx(s.T()) + + if session != nil { + domain := mock.Ctx.GetTargetURICookieDomain(targetURI) + + provider, err := mock.Ctx.GetCookieDomainSessionProvider(domain) + s.Require().NoError(err) + + s.Require().NoError(provider.SaveSession(mock.Ctx.RequestCtx, *session)) + } + + return mock +} + +func (s *AuthzSuite) RequireParseRequestURI(rawURL string) *url.URL { + u, err := url.ParseRequestURI(rawURL) + + s.Require().NoError(err) + + return u +} + +type urlpair struct { + TargetURI *url.URL + AutheliaURI *url.URL +} + +func (s *AuthzSuite) Builder() (builder *AuthzBuilder) { + if s.builder != nil { + return s.builder + } + + switch s.implementation { + case AuthzImplExtAuthz: + return NewAuthzBuilder().WithImplementationExtAuthz() + case AuthzImplForwardAuth: + return NewAuthzBuilder().WithImplementationForwardAuth() + case AuthzImplAuthRequest: + return NewAuthzBuilder().WithImplementationAuthRequest() + case AuthzImplLegacy: + return NewAuthzBuilder().WithImplementationLegacy() + } + + s.T().FailNow() + + return +} + +func (s *AuthzSuite) TestShouldNotBeAbleToParseBasicAuth() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://test.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpaaaaaaaaaaaaaaaa") + + authz.Handler(mock.Ctx) + + switch s.implementation { + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) + default: + s.Equal(fasthttp.StatusProxyAuthRequired, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate))) + } +} + +func (s *AuthzSuite) TestShouldApplyDefaultPolicy() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://test.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, + }, nil) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusForbidden, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) +} + +func (s *AuthzSuite) TestShouldDenyObject() { + if s.setRequest == nil { + s.T().Skip() + } + + testCases := []struct { + name string + value string + }{ + { + "NotProtected", + "https://test.not-a-protected-domain.com", + }, + { + "Insecure", + "http://test.example.com", + }, + } + + authz := s.Builder().Build() + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI(tc.value) + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + authz.Handler(mock.Ctx) + + assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + }) + } +} + +func (s *AuthzSuite) TestShouldApplyPolicyOfBypassDomain() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://bypass.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, + }, nil) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) +} + +func (s *AuthzSuite) TestShouldVerifyFailureToGetDetailsUsingBasicScheme() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://bypass.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + + gomock.InOrder( + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil), + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(nil, fmt.Errorf("generic failure")), + ) + + authz.Handler(mock.Ctx) + + switch s.implementation { + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) + default: + s.Equal(fasthttp.StatusProxyAuthRequired, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate))) + } +} + +func (s *AuthzSuite) TestShouldNotFailOnMissingEmail() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Clock.Set(time.Now()) + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://bypass.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = testUsername + userSession.DisplayName = "John Smith" + userSession.Groups = []string{"abc,123"} + userSession.Emails = nil + userSession.AuthenticationLevel = authentication.OneFactor + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + s.Equal(testUsername, string(mock.Ctx.Response.Header.PeekBytes(headerRemoteUser))) + s.Equal("John Smith", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteName))) + s.Equal("abc,123", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteGroups))) +} + +func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomain() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, + }, nil) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) +} + +func (s *AuthzSuite) TestShouldHandleAnyCaseSchemeParameter() { + if s.setRequest == nil { + s.T().Skip() + } + + testCases := []struct { + name, scheme string + }{ + {"Standard", "Basic"}, + {"LowerCase", "basic"}, + {"UpperCase", "BASIC"}, + {"MixedCase", "BaSIc"}, + } + + authz := s.Builder().Build() + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, fmt.Sprintf("%s am9objpwYXNzd29yZA==", tc.scheme)) + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, + }, nil) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) + }) + } +} + +func (s *AuthzSuite) TestShouldApplyPolicyOfTwoFactorDomain() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://two-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, + }, nil) + + authz.Handler(mock.Ctx) + + switch s.implementation { + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) + default: + s.Equal(fasthttp.StatusProxyAuthRequired, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate))) + } +} + +func (s *AuthzSuite) TestShouldApplyPolicyOfDenyDomain() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.Builder().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://deny.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, + }, nil) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusForbidden, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) +} + +func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomainWithAuthorizationHeader() { + if s.setRequest == nil { + s.T().Skip() + } + + // Equivalent of TestShouldVerifyAuthBasicArgOk. + + builder := NewAuthzBuilder().WithImplementationLegacy() + + builder = builder.WithStrategies( + NewHeaderAuthorizationAuthnStrategy(), + NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), + NewCookieSessionAuthnStrategy(builder.config.RefreshInterval), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderAuthorization, "Basic am9objpwYXNzd29yZA==") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil) + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{ + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, + }, nil) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) +} + +func (s *AuthzSuite) TestShouldHandleAuthzWithoutHeaderNoCookie() { + if s.setRequest == nil { + s.T().Skip() + } + + // Equivalent of TestShouldVerifyAuthBasicArgFailingNoHeader. + + builder := NewAuthzBuilder().WithImplementationLegacy() + + builder = builder.WithStrategies( + NewHeaderAuthorizationAuthnStrategy(), + NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) +} + +func (s *AuthzSuite) TestShouldHandleAuthzWithEmptyAuthorizationHeader() { + if s.setRequest == nil { + s.T().Skip() + } + + // Equivalent of TestShouldVerifyAuthBasicArgFailingEmptyHeader. + + builder := NewAuthzBuilder().WithImplementationLegacy() + + builder = builder.WithStrategies( + NewHeaderAuthorizationAuthnStrategy(), + NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderAuthorization, "") + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) +} + +func (s *AuthzSuite) TestShouldHandleAuthzWithAuthorizationHeaderInvalidPassword() { + if s.setRequest == nil { + s.T().Skip() + } + + // Equivalent of TestShouldVerifyAuthBasicArgFailingWrongPassword. + + builder := NewAuthzBuilder().WithImplementationLegacy() + + builder = builder.WithStrategies( + NewHeaderAuthorizationAuthnStrategy(), + NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderAuthorization, "Basic am9objpwYXNzd29yZA==") + + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(false, nil) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) +} + +func (s *AuthzSuite) TestShouldHandleAuthzWithIncorrectAuthHeader() { // TestShouldVerifyAuthBasicArgFailingWrongHeader. + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewHeaderAuthorizationAuthnStrategy(), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) +} + +func (s *AuthzSuite) TestShouldDestroySessionWhenInactiveForTooLong() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(testInactivity), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + past := mock.Clock.Now().Add(-1 * time.Hour) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://two-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = testUsername + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = past.Unix() + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + authz.Handler(mock.Ctx) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal("", userSession.Username) + s.Equal(authentication.NotAuthenticated, userSession.AuthenticationLevel) + s.Equal(mock.Clock.Now().Unix(), userSession.LastActivity) +} + +func (s *AuthzSuite) TestShouldNotDestroySessionWhenInactiveForTooLongRememberMe() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(testInactivity), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://two-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = testUsername + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = 0 + userSession.KeepMeLoggedIn = true + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + authz.Handler(mock.Ctx) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(testUsername, userSession.Username) + s.Equal(authentication.TwoFactor, userSession.AuthenticationLevel) + s.Equal(int64(0), userSession.LastActivity) +} + +func (s *AuthzSuite) TestShouldNotDestroySessionWhenNotInactiveForTooLong() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(testInactivity), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://two-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + last := mock.Clock.Now().Add(-1 * time.Second) + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = testUsername + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = last.Unix() + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + authz.Handler(mock.Ctx) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(testUsername, userSession.Username) + s.Equal(authentication.TwoFactor, userSession.AuthenticationLevel) + s.Equal(mock.Clock.Now().Unix(), userSession.LastActivity) +} + +func (s *AuthzSuite) TestShouldUpdateInactivityTimestampEvenWhenHittingForbiddenResources() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(testInactivity), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://deny.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + last := mock.Clock.Now().Add(-3 * time.Second) + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = testUsername + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = last.Unix() + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + authz.Handler(mock.Ctx) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(testUsername, userSession.Username) + s.Equal(authentication.TwoFactor, userSession.AuthenticationLevel) + s.Equal(mock.Clock.Now().Unix(), userSession.LastActivity) +} + +func (s *AuthzSuite) TestShouldNotRefreshUserDetailsFromBackendWhenRefreshDisabled() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(-1 * time.Second), + ) + + authz := builder.Build() + + user := &authentication.UserDetails{ + Username: "john", + Groups: []string{ + "admin", + "users", + }, + Emails: []string{ + "john@example.com", + }, + } + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Clock.Set(time.Now()) + + mock.Ctx.Clock = &mock.Clock + mock.Ctx.Configuration.AuthenticationBackend.RefreshInterval = schema.ProfileRefreshDisabled + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://two-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = user.Username + userSession.Groups = user.Groups + userSession.Emails = user.Emails + userSession.KeepMeLoggedIn = true + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = mock.Clock.Now().Unix() + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + mock.UserProviderMock.EXPECT().GetDetails("john").Times(0) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + + targetURI = s.RequireParseRequestURI("https://admin.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(user.Username, userSession.Username) + s.Equal(authentication.TwoFactor, userSession.AuthenticationLevel) + s.Equal(mock.Clock.Now().Unix(), userSession.LastActivity) + s.Require().Len(userSession.Groups, 2) + s.Equal("admin", userSession.Groups[0]) + s.Equal("users", userSession.Groups[1]) + s.Equal(utils.RFC3339Zero, userSession.RefreshTTL.Unix()) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(user.Username, userSession.Username) + s.Equal(authentication.TwoFactor, userSession.AuthenticationLevel) + s.Equal(mock.Clock.Now().Unix(), userSession.LastActivity) + s.Require().Len(userSession.Groups, 2) + s.Equal("admin", userSession.Groups[0]) + s.Equal("users", userSession.Groups[1]) + s.Equal(utils.RFC3339Zero, userSession.RefreshTTL.Unix()) +} + +func (s *AuthzSuite) TestShouldDestroySessionWhenUserDoesNotExist() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(5 * time.Minute), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://two-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + user := &authentication.UserDetails{ + Username: "john", + Groups: []string{ + "admin", + "users", + }, + Emails: []string{ + "john@example.com", + }, + } + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = user.Username + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = mock.Clock.Now().Unix() + userSession.RefreshTTL = mock.Clock.Now().Add(-1 * time.Minute) + userSession.Groups = user.Groups + userSession.Emails = user.Emails + userSession.KeepMeLoggedIn = true + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + gomock.InOrder( + mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1), + mock.UserProviderMock.EXPECT().GetDetails("john").Return(nil, authentication.ErrUserNotFound).Times(1), + ) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(mock.Clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) + + userSession.RefreshTTL = mock.Clock.Now().Add(-1 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + authz.Handler(mock.Ctx) + + switch s.implementation { + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + default: + s.Equal(fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + } + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal("", userSession.Username) + s.Equal(authentication.NotAuthenticated, userSession.AuthenticationLevel) + s.True(userSession.IsAnonymous()) +} + +func (s *AuthzSuite) TestShouldUpdateRemovedUserGroupsFromBackendAndDeny() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(5 * time.Minute), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://admin.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + user := &authentication.UserDetails{ + Username: "john", + Groups: []string{ + "admin", + "users", + }, + Emails: []string{ + "john@example.com", + }, + } + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = user.Username + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = mock.Clock.Now().Unix() + userSession.RefreshTTL = mock.Clock.Now().Add(-1 * time.Minute) + userSession.Groups = user.Groups + userSession.Emails = user.Emails + userSession.KeepMeLoggedIn = true + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + gomock.InOrder( + mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1), + mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1), + ) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(mock.Clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) + s.Require().Len(userSession.Groups, 2) + s.Require().Equal("admin", userSession.Groups[0]) + s.Require().Equal("users", userSession.Groups[1]) + + user.Groups = []string{"users"} + + mock.Clock.Set(mock.Clock.Now().Add(6 * time.Minute)) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusForbidden, mock.Ctx.Response.StatusCode()) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(mock.Clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) + s.Require().Len(userSession.Groups, 1) + s.Require().Equal("users", userSession.Groups[0]) +} + +func (s *AuthzSuite) TestShouldUpdateAddedUserGroupsFromBackendAndDeny() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(5 * time.Minute), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://admin.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + user := &authentication.UserDetails{ + Username: "john", + Groups: []string{ + "users", + }, + Emails: []string{ + "john@example.com", + }, + } + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = user.Username + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = mock.Clock.Now().Unix() + userSession.RefreshTTL = mock.Clock.Now().Add(-1 * time.Minute) + userSession.Groups = user.Groups + userSession.Emails = user.Emails + userSession.KeepMeLoggedIn = true + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + gomock.InOrder( + mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1), + mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1), + ) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusForbidden, mock.Ctx.Response.StatusCode()) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(mock.Clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) + s.Require().Len(userSession.Groups, 1) + s.Require().Equal("users", userSession.Groups[0]) + + user.Groups = []string{"admin", "users"} + + mock.Clock.Set(mock.Clock.Now().Add(6 * time.Minute)) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(mock.Clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) + s.Require().Len(userSession.Groups, 2) + s.Require().Equal("admin", userSession.Groups[0]) + s.Require().Equal("users", userSession.Groups[1]) +} + +func (s *AuthzSuite) TestShouldCheckValidSessionUsernameHeaderAndReturn200() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(testInactivity), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.SetBytesK(headerSessionUsername, testUsername) + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = testUsername + userSession.AuthenticationLevel = authentication.OneFactor + userSession.LastActivity = mock.Clock.Now().Unix() + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal(testUsername, userSession.Username) + s.Equal(authentication.OneFactor, userSession.AuthenticationLevel) + s.Equal(mock.Clock.Now().Unix(), userSession.LastActivity) +} + +func (s *AuthzSuite) TestShouldCheckInvalidSessionUsernameHeaderAndReturn401AndDestroySession() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(testInactivity), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.SetBytesK(headerSessionUsername, "root") + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = testUsername + userSession.AuthenticationLevel = authentication.OneFactor + userSession.LastActivity = mock.Clock.Now().Unix() + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + authz.Handler(mock.Ctx) + + switch s.implementation { + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + default: + s.Equal(fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + location := s.RequireParseRequestURI(mock.Ctx.Configuration.Session.Cookies[0].AutheliaURL.String()) + + if location.Path == "" { + location.Path = "/" + } + + query := location.Query() + query.Set(queryArgRD, targetURI.String()) + query.Set(queryArgRM, fasthttp.MethodGet) + + location.RawQuery = query.Encode() + + s.Equal(location.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + } + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal("", userSession.Username) + s.Equal(authentication.NotAuthenticated, userSession.AuthenticationLevel) + s.Equal(mock.Clock.Now().Unix(), userSession.LastActivity) +} + +func (s *AuthzSuite) TestShouldNotRedirectRequestsForBypassACLWhenInactiveForTooLong() { + if s.setRequest == nil { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(testInactivity), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Clock = &mock.Clock + + mock.Clock.Set(time.Now()) + + past := mock.Clock.Now().Add(-24 * time.Hour) + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://bypass.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + userSession, err := mock.Ctx.GetSession() + s.Require().NoError(err) + + userSession.Username = testUsername + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = past.Unix() + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal("", userSession.Username) + s.Equal(authentication.NotAuthenticated, userSession.AuthenticationLevel) + s.Equal(mock.Clock.Now().Unix(), userSession.LastActivity) + + targetURI = s.RequireParseRequestURI("https://two-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + authz.Handler(mock.Ctx) + + switch s.implementation { + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + default: + s.Equal(fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + location := s.RequireParseRequestURI(mock.Ctx.Configuration.Session.Cookies[0].AutheliaURL.String()) + + if location.Path == "" { + location.Path = "/" + } + + query := location.Query() + query.Set(queryArgRD, targetURI.String()) + query.Set(queryArgRM, fasthttp.MethodGet) + + location.RawQuery = query.Encode() + + s.Equal(location.String(), string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderLocation))) + } +} + +func (s *AuthzSuite) TestShouldFailToParsePortalURL() { + if s.setRequest == nil || s.implementation == AuthzImplAuthRequest { + s.T().Skip() + } + + builder := s.Builder() + + builder = builder.WithStrategies( + NewCookieSessionAuthnStrategy(testInactivity), + ) + + authz := builder.Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity + + for i, cookie := range mock.Ctx.Configuration.Session.Cookies { + mock.Ctx.Configuration.Session.Cookies[i].AutheliaURL = s.RequireParseRequestURI(fmt.Sprintf("https://auth.%s", cookie.Domain)) + } + + mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) + + targetURI := s.RequireParseRequestURI("https://bypass.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + switch s.implementation { + case AuthzImplLegacy: + mock.Ctx.RequestCtx.QueryArgs().Set(queryArgRD, "JKL$#N%KJ#@$N") + case AuthzImplForwardAuth: + mock.Ctx.RequestCtx.QueryArgs().Set("authelia_url", "JKL$#N%KJ#@$N") + case AuthzImplExtAuthz: + mock.Ctx.Request.Header.Set("X-Authelia-URL", "JKL$#N%KJ#@$N") + } + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) +} + +func setRequestXHRValues(ctx *middlewares.AutheliaCtx, accept, xhr bool) { + if accept { + ctx.Request.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf-8") + } + + if xhr { + ctx.Request.Header.Set(fasthttp.HeaderXRequestedWith, "XMLHttpRequest") + } +} diff --git a/internal/handlers/handler_authz_types.go b/internal/handlers/handler_authz_types.go new file mode 100644 index 000000000..549e7505f --- /dev/null +++ b/internal/handlers/handler_authz_types.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "net/url" + "time" + + "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" +) + +// Authz is a type which is a effectively is a middlewares.RequestHandler for authorization requests. +type Authz struct { + config AuthzConfig + + strategies []AuthnStrategy + + handleGetObject HandlerAuthzGetObject + + handleGetAutheliaURL HandlerAuthzGetAutheliaURL + + handleAuthorized HandlerAuthzAuthorized + handleUnauthorized HandlerAuthzUnauthorized + + legacy bool +} + +// HandlerAuthzUnauthorized is a Authz handler func that handles unauthorized responses. +type HandlerAuthzUnauthorized func(ctx *middlewares.AutheliaCtx, authn *Authn, redirectionURL *url.URL) + +// HandlerAuthzAuthorized is a Authz handler func that handles authorized responses. +type HandlerAuthzAuthorized func(ctx *middlewares.AutheliaCtx, authn *Authn) + +// HandlerAuthzGetAutheliaURL is a Authz handler func that handles retrieval of the Portal URL. +type HandlerAuthzGetAutheliaURL func(ctx *middlewares.AutheliaCtx) (portalURL *url.URL, err error) + +// HandlerAuthzGetRedirectionURL is a Authz handler func that handles retrieval of the Redirection URL. +type HandlerAuthzGetRedirectionURL func(ctx *middlewares.AutheliaCtx, object *authorization.Object) (redirectionURL *url.URL, err error) + +// HandlerAuthzGetObject is a Authz handler func that handles retrieval of the authorization.Object to authorize. +type HandlerAuthzGetObject func(ctx *middlewares.AutheliaCtx) (object authorization.Object, err error) + +// HandlerAuthzVerifyObject is a Authz handler func that handles authorization of the authorization.Object. +type HandlerAuthzVerifyObject func(ctx *middlewares.AutheliaCtx, object authorization.Object) (err error) + +// AuthnType is an auth type. +type AuthnType int + +const ( + // AuthnTypeNone is a nil Authentication AuthnType. + AuthnTypeNone AuthnType = iota + + // AuthnTypeCookie is an Authentication AuthnType based on the Cookie header. + AuthnTypeCookie + + // AuthnTypeProxyAuthorization is an Authentication AuthnType based on the Proxy-Authorization header. + AuthnTypeProxyAuthorization + + // AuthnTypeAuthorization is an Authentication AuthnType based on the Authorization header. + AuthnTypeAuthorization +) + +// Authn is authentication. +type Authn struct { + Username string + Method string + + Details authentication.UserDetails + Level authentication.Level + Object authorization.Object + Type AuthnType +} + +// AuthzConfig represents the configuration elements of the Authz type. +type AuthzConfig struct { + RefreshInterval time.Duration + Domains []AuthzDomain +} + +// AuthzDomain represents a domain for the AuthzConfig. +type AuthzDomain struct { + Name string + PortalURL *url.URL +} + +// AuthzBuilder is a builder pattern for the Authz type. +type AuthzBuilder struct { + config AuthzConfig + impl AuthzImplementation + strategies []AuthnStrategy +} + +// AuthnStrategy is a strategy used for Authz authentication. +type AuthnStrategy interface { + Get(ctx *middlewares.AutheliaCtx, provider *session.Session) (authn Authn, err error) + CanHandleUnauthorized() (handle bool) + HandleUnauthorized(ctx *middlewares.AutheliaCtx, authn *Authn, redirectionURL *url.URL) +} + +// AuthzResult is a result for Authz response handling determination. +type AuthzResult int + +const ( + // AuthzResultForbidden means the user is forbidden the access to a resource. + AuthzResultForbidden AuthzResult = iota + + // AuthzResultUnauthorized means the user can access the resource with more permissions. + AuthzResultUnauthorized + + // AuthzResultAuthorized means the user is authorized given her current permissions. + AuthzResultAuthorized +) + +// AuthzImplementation represents an Authz implementation. +type AuthzImplementation int + +// AuthnStrategy names. +const ( + AuthnStrategyCookieSession = "CookieSession" + AuthnStrategyHeaderAuthorization = "HeaderAuthorization" + AuthnStrategyHeaderProxyAuthorization = "HeaderProxyAuthorization" + AuthnStrategyHeaderAuthRequestProxyAuthorization = "HeaderAuthRequestProxyAuthorization" + AuthnStrategyHeaderLegacy = "HeaderLegacy" +) + +const ( + // AuthzImplLegacy is the legacy Authz implementation (VerifyGET). + AuthzImplLegacy AuthzImplementation = iota + + // AuthzImplForwardAuth is the modern Forward Auth Authz implementation which is used by Caddy and Traefik. + AuthzImplForwardAuth + + // AuthzImplAuthRequest is the modern Auth Request Authz implementation which is used by NGINX and modelled after + // the ingress-nginx k8s ingress. + AuthzImplAuthRequest + + // AuthzImplExtAuthz is the modern ExtAuthz Authz implementation which is used by Envoy. + AuthzImplExtAuthz +) + +// String returns the text representation of this AuthzImplementation. +func (i AuthzImplementation) String() string { + switch i { + case AuthzImplLegacy: + return "Legacy" + case AuthzImplForwardAuth: + return "ForwardAuth" + case AuthzImplAuthRequest: + return "AuthRequest" + case AuthzImplExtAuthz: + return "ExtAuthz" + default: + return "" + } +} diff --git a/internal/handlers/handler_authz_util.go b/internal/handlers/handler_authz_util.go new file mode 100644 index 000000000..5fadcd953 --- /dev/null +++ b/internal/handlers/handler_authz_util.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "fmt" + "strings" + + "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/utils" +) + +func friendlyMethod(m string) (fm string) { + switch m { + case "": + return "unknown" + default: + return m + } +} + +func friendlyUsername(username string) (fusername string) { + switch username { + case "": + return "" + default: + return username + } +} + +func isAuthzResult(level authentication.Level, required authorization.Level, ruleHasSubject bool) AuthzResult { + switch { + case required == authorization.Bypass: + return AuthzResultAuthorized + case required == authorization.Denied && (level != authentication.NotAuthenticated || !ruleHasSubject): + // If the user is not anonymous, it means that we went through all the rules related to that user identity and + // can safely conclude their access is actually forbidden. If a user is anonymous however this is not actually + // possible without some more advanced logic. + return AuthzResultForbidden + case required == authorization.OneFactor && level >= authentication.OneFactor, + required == authorization.TwoFactor && level >= authentication.TwoFactor: + return AuthzResultAuthorized + default: + return AuthzResultUnauthorized + } +} + +// generateVerifySessionHasUpToDateProfileTraceLogs is used to generate trace logs only when trace logging is enabled. +// The information calculated in this function is completely useless other than trace for now. +func generateVerifySessionHasUpToDateProfileTraceLogs(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, + details *authentication.UserDetails) { + groupsAdded, groupsRemoved := utils.StringSlicesDelta(userSession.Groups, details.Groups) + emailsAdded, emailsRemoved := utils.StringSlicesDelta(userSession.Emails, details.Emails) + nameDelta := userSession.DisplayName != details.DisplayName + + var groupsDelta []string + if len(groupsAdded) != 0 { + groupsDelta = append(groupsDelta, fmt.Sprintf("added: %s.", strings.Join(groupsAdded, ", "))) + } + + if len(groupsRemoved) != 0 { + groupsDelta = append(groupsDelta, fmt.Sprintf("removed: %s.", strings.Join(groupsRemoved, ", "))) + } + + if len(groupsDelta) != 0 { + ctx.Logger.Tracef("Updated groups detected for %s. %s", userSession.Username, strings.Join(groupsDelta, " ")) + } else { + ctx.Logger.Tracef("No updated groups detected for %s", userSession.Username) + } + + var emailsDelta []string + if len(emailsAdded) != 0 { + emailsDelta = append(emailsDelta, fmt.Sprintf("added: %s.", strings.Join(emailsAdded, ", "))) + } + + if len(emailsRemoved) != 0 { + emailsDelta = append(emailsDelta, fmt.Sprintf("removed: %s.", strings.Join(emailsRemoved, ", "))) + } + + if len(emailsDelta) != 0 { + ctx.Logger.Tracef("Updated emails detected for %s. %s", userSession.Username, strings.Join(emailsDelta, " ")) + } else { + ctx.Logger.Tracef("No updated emails detected for %s", userSession.Username) + } + + if nameDelta { + ctx.Logger.Tracef("Updated display name detected for %s. Added: %s. Removed: %s.", userSession.Username, details.DisplayName, userSession.DisplayName) + } else { + ctx.Logger.Tracef("No updated display name detected for %s", userSession.Username) + } +} diff --git a/internal/handlers/handler_checks_safe_redirection.go b/internal/handlers/handler_checks_safe_redirection.go index be1eef04d..72b2bc215 100644 --- a/internal/handlers/handler_checks_safe_redirection.go +++ b/internal/handlers/handler_checks_safe_redirection.go @@ -5,13 +5,22 @@ import ( "net/url" "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" ) // CheckSafeRedirectionPOST handler checking whether the redirection to a given URL provided in body is safe. func CheckSafeRedirectionPOST(ctx *middlewares.AutheliaCtx) { - userSession := ctx.GetSession() + var ( + s session.UserSession + err error + ) - if userSession.IsAnonymous() { + if s, err = ctx.GetSession(); err != nil { + ctx.ReplyUnauthorized() + return + } + + if s.IsAnonymous() { ctx.ReplyUnauthorized() return } @@ -19,7 +28,6 @@ func CheckSafeRedirectionPOST(ctx *middlewares.AutheliaCtx) { var ( bodyJSON checkURIWithinDomainRequestBody targetURI *url.URL - err error ) if err = ctx.ParseBody(&bodyJSON); err != nil { diff --git a/internal/handlers/handler_checks_safe_redirection_test.go b/internal/handlers/handler_checks_safe_redirection_test.go index cb5ec41de..27afaacc2 100644 --- a/internal/handlers/handler_checks_safe_redirection_test.go +++ b/internal/handlers/handler_checks_safe_redirection_test.go @@ -21,35 +21,35 @@ func TestCheckSafeRedirection(t *testing.T) { }{ { "ShouldReturnUnauthorized", - session.UserSession{AuthenticationLevel: authentication.NotAuthenticated}, + session.UserSession{CookieDomain: "example.com", AuthenticationLevel: authentication.NotAuthenticated}, "http://myapp.example.com", fasthttp.StatusUnauthorized, false, }, { "ShouldReturnTrueOnGoodDomain", - session.UserSession{Username: "john", AuthenticationLevel: authentication.OneFactor}, + session.UserSession{CookieDomain: "example.com", Username: "john", AuthenticationLevel: authentication.OneFactor}, "https://myapp.example.com", fasthttp.StatusOK, true, }, { "ShouldReturnFalseOnGoodDomainWithBadScheme", - session.UserSession{Username: "john", AuthenticationLevel: authentication.OneFactor}, + session.UserSession{CookieDomain: "example.com", Username: "john", AuthenticationLevel: authentication.OneFactor}, "http://myapp.example.com", fasthttp.StatusOK, false, }, { "ShouldReturnFalseOnBadDomainWithGoodScheme", - session.UserSession{Username: "john", AuthenticationLevel: authentication.OneFactor}, + session.UserSession{CookieDomain: "example.com", Username: "john", AuthenticationLevel: authentication.OneFactor}, "https://myapp.notgood.com", fasthttp.StatusOK, false, }, { "ShouldReturnFalseOnBadDomainWithBadScheme", - session.UserSession{Username: "john", AuthenticationLevel: authentication.OneFactor}, + session.UserSession{CookieDomain: "example.com", Username: "john", AuthenticationLevel: authentication.OneFactor}, "http://myapp.notgood.com", fasthttp.StatusOK, false, @@ -80,9 +80,11 @@ func TestCheckSafeRedirection(t *testing.T) { func TestShouldFailOnInvalidBody(t *testing.T) { mock := mocks.NewMockAutheliaCtxWithUserSession(t, session.UserSession{ + CookieDomain: exampleDotCom, Username: "john", AuthenticationLevel: authentication.OneFactor, }) + defer mock.Close() mock.Ctx.Configuration.Session.Domain = exampleDotCom @@ -94,6 +96,7 @@ func TestShouldFailOnInvalidBody(t *testing.T) { func TestShouldFailOnInvalidURL(t *testing.T) { mock := mocks.NewMockAutheliaCtxWithUserSession(t, session.UserSession{ + CookieDomain: exampleDotCom, Username: "john", AuthenticationLevel: authentication.OneFactor, }) diff --git a/internal/handlers/handler_firstfactor.go b/internal/handlers/handler_firstfactor.go index 3f896fb28..75aea5e02 100644 --- a/internal/handlers/handler_firstfactor.go +++ b/internal/handlers/handler_firstfactor.go @@ -4,9 +4,10 @@ import ( "errors" "time" + "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/regulation" - "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/utils" ) // FirstFactorPOST is the handler performing the first factory. @@ -90,7 +91,7 @@ func FirstFactorPOST(delayFunc middlewares.TimingAttackDelayFunc) middlewares.Re return } - newSession := session.NewDefaultUserSession() + newSession := provider.NewDefaultUserSession() // Reset all values from previous session except OIDC workflow before regenerating the cookie. if err = ctx.SaveSession(newSession); err != nil { @@ -159,3 +160,22 @@ func FirstFactorPOST(delayFunc middlewares.TimingAttackDelayFunc) middlewares.Re } } } + +func getProfileRefreshSettings(cfg schema.AuthenticationBackend) (refresh bool, refreshInterval time.Duration) { + if cfg.LDAP != nil { + if cfg.RefreshInterval == schema.ProfileRefreshDisabled { + refresh = false + refreshInterval = 0 + } else { + refresh = true + + if cfg.RefreshInterval != schema.ProfileRefreshAlways { + refreshInterval, _ = utils.ParseDurationString(cfg.RefreshInterval) + } else { + refreshInterval = schema.RefreshIntervalAlways + } + } + } + + return refresh, refreshInterval +} diff --git a/internal/handlers/handler_firstfactor_test.go b/internal/handlers/handler_firstfactor_test.go index e2864279e..d6f8082ad 100644 --- a/internal/handlers/handler_firstfactor_test.go +++ b/internal/handlers/handler_firstfactor_test.go @@ -209,13 +209,14 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeChecked() { assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode()) assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body()) - // And store authentication in session. - session := s.mock.Ctx.GetSession() - assert.Equal(s.T(), "test", session.Username) - assert.Equal(s.T(), true, session.KeepMeLoggedIn) - assert.Equal(s.T(), authentication.OneFactor, session.AuthenticationLevel) - assert.Equal(s.T(), []string{"test@example.com"}, session.Emails) - assert.Equal(s.T(), []string{"dev", "admins"}, session.Groups) + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + + assert.Equal(s.T(), "test", userSession.Username) + assert.Equal(s.T(), true, userSession.KeepMeLoggedIn) + assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel) + assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails) + assert.Equal(s.T(), []string{"dev", "admins"}, userSession.Groups) } func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() { @@ -250,13 +251,14 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() { assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode()) assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body()) - // And store authentication in session. - session := s.mock.Ctx.GetSession() - assert.Equal(s.T(), "test", session.Username) - assert.Equal(s.T(), false, session.KeepMeLoggedIn) - assert.Equal(s.T(), authentication.OneFactor, session.AuthenticationLevel) - assert.Equal(s.T(), []string{"test@example.com"}, session.Emails) - assert.Equal(s.T(), []string{"dev", "admins"}, session.Groups) + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + + assert.Equal(s.T(), "test", userSession.Username) + assert.Equal(s.T(), false, userSession.KeepMeLoggedIn) + assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel) + assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails) + assert.Equal(s.T(), []string{"dev", "admins"}, userSession.Groups) } func (s *FirstFactorSuite) TestShouldSaveUsernameFromAuthenticationBackendInSession() { @@ -294,13 +296,14 @@ func (s *FirstFactorSuite) TestShouldSaveUsernameFromAuthenticationBackendInSess assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode()) assert.Equal(s.T(), []byte("{\"status\":\"OK\"}"), s.mock.Ctx.Response.Body()) - // And store authentication in session. - session := s.mock.Ctx.GetSession() - assert.Equal(s.T(), "Test", session.Username) - assert.Equal(s.T(), true, session.KeepMeLoggedIn) - assert.Equal(s.T(), authentication.OneFactor, session.AuthenticationLevel) - assert.Equal(s.T(), []string{"test@example.com"}, session.Emails) - assert.Equal(s.T(), []string{"dev", "admins"}, session.Groups) + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + + assert.Equal(s.T(), "Test", userSession.Username) + assert.Equal(s.T(), true, userSession.KeepMeLoggedIn) + assert.Equal(s.T(), authentication.OneFactor, userSession.AuthenticationLevel) + assert.Equal(s.T(), []string{"test@example.com"}, userSession.Emails) + assert.Equal(s.T(), []string{"dev", "admins"}, userSession.Groups) } type FirstFactorRedirectionSuite struct { @@ -312,7 +315,7 @@ type FirstFactorRedirectionSuite struct { func (s *FirstFactorRedirectionSuite) SetupTest() { s.mock = mocks.NewMockAutheliaCtx(s.T()) s.mock.Ctx.Configuration.DefaultRedirectionURL = "https://default.local" - s.mock.Ctx.Configuration.AccessControl.DefaultPolicy = "bypass" + s.mock.Ctx.Configuration.AccessControl.DefaultPolicy = testBypass s.mock.Ctx.Configuration.AccessControl.Rules = []schema.ACLRule{ { Domains: []string{"default.local"}, diff --git a/internal/handlers/handler_logout_test.go b/internal/handlers/handler_logout_test.go index 1b14f2e1a..9ffcd2f99 100644 --- a/internal/handlers/handler_logout_test.go +++ b/internal/handlers/handler_logout_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/authelia/authelia/v4/internal/mocks" @@ -19,10 +18,14 @@ type LogoutSuite struct { func (s *LogoutSuite) SetupTest() { s.mock = mocks.NewMockAutheliaCtx(s.T()) - userSession := s.mock.Ctx.GetSession() + provider, err := s.mock.Ctx.GetSessionProvider() + s.Assert().NoError(err) + + userSession, err := provider.GetSession(s.mock.Ctx.RequestCtx) + s.Assert().NoError(err) + userSession.Username = testUsername - err := s.mock.Ctx.SaveSession(userSession) - require.NoError(s.T(), err) + s.Assert().NoError(provider.SaveSession(s.mock.Ctx.RequestCtx, userSession)) } func (s *LogoutSuite) TearDownTest() { diff --git a/internal/handlers/handler_oidc_authorization.go b/internal/handlers/handler_oidc_authorization.go index 887f11088..57f686068 100644 --- a/internal/handlers/handler_oidc_authorization.go +++ b/internal/handlers/handler_oidc_authorization.go @@ -11,6 +11,7 @@ import ( "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/oidc" + "github.com/authelia/authelia/v4/internal/session" ) // OpenIDConnectAuthorization handles GET/POST requests to the OpenID Connect 1.0 Authorization endpoint. @@ -64,13 +65,20 @@ func OpenIDConnectAuthorization(ctx *middlewares.AutheliaCtx, rw http.ResponseWr issuer = ctx.RootURL() - userSession := ctx.GetSession() - var ( - consent *model.OAuth2ConsentSession - handled bool + userSession session.UserSession + consent *model.OAuth2ConsentSession + handled bool ) + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.Errorf("Authorization Request with id '%s' on client with id '%s' could not be processed: error occurred obtaining session information: %+v", requester.GetID(), client.GetID(), err) + + ctx.Providers.OpenIDConnect.WriteAuthorizeError(ctx, rw, requester, fosite.ErrServerError.WithHint("Could not obtain the user session.")) + + return + } + if consent, handled = handleOIDCAuthorizationConsent(ctx, issuer, client, userSession, rw, r, requester); handled { return } diff --git a/internal/handlers/handler_oidc_consent.go b/internal/handlers/handler_oidc_consent.go index 98935cba0..bea6e9067 100644 --- a/internal/handlers/handler_oidc_consent.go +++ b/internal/handlers/handler_oidc_consent.go @@ -156,7 +156,12 @@ func oidcConsentGetSessionsAndClient(ctx *middlewares.AutheliaCtx, consentID uui err error ) - userSession = ctx.GetSession() + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.Errorf("Unable to load user session for challenge id '%s': %v", consentID, err) + ctx.ReplyForbidden() + + return userSession, nil, nil, true + } if consent, err = ctx.Providers.StorageProvider.LoadOAuth2ConsentSessionByChallengeID(ctx, consentID); err != nil { ctx.Logger.Errorf("Unable to load consent session with challenge id '%s': %v", consentID, err) diff --git a/internal/handlers/handler_register_duo_device.go b/internal/handlers/handler_register_duo_device.go index 8182cbdce..ca93950e6 100644 --- a/internal/handlers/handler_register_duo_device.go +++ b/internal/handlers/handler_register_duo_device.go @@ -8,21 +8,31 @@ import ( "github.com/authelia/authelia/v4/internal/duo" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" + "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/utils" ) // DuoDevicesGET handler for retrieving available devices and capabilities from duo api. func DuoDevicesGET(duoAPI duo.API) middlewares.RequestHandler { return func(ctx *middlewares.AutheliaCtx) { - userSession := ctx.GetSession() + var ( + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Error(fmt.Errorf("failed to get session data: %w", err), messageMFAValidationFailed) + return + } + values := url.Values{} values.Set("username", userSession.Username) ctx.Logger.Debugf("Starting Duo PreAuth for %s", userSession.Username) - result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI) + result, message, devices, enrollURL, err := DuoPreAuth(ctx, &userSession, duoAPI) if err != nil { - ctx.Error(fmt.Errorf("duo PreAuth API errored: %s", err), messageMFAValidationFailed) + ctx.Error(fmt.Errorf("duo PreAuth API errored: %w", err), messageMFAValidationFailed) return } @@ -80,39 +90,55 @@ func DuoDevicesGET(duoAPI duo.API) middlewares.RequestHandler { // DuoDevicePOST update the user preferences regarding Duo device and method. func DuoDevicePOST(ctx *middlewares.AutheliaCtx) { - device := DuoDeviceBody{} + bodyJSON := DuoDeviceBody{} - err := ctx.ParseBody(&device) - if err != nil { + var ( + userSession session.UserSession + err error + ) + + if err = ctx.ParseBody(&bodyJSON); err != nil { ctx.Error(err, messageMFAValidationFailed) return } - if !utils.IsStringInSlice(device.Method, duo.PossibleMethods) { - ctx.Error(fmt.Errorf("unknown method '%s', it should be one of %s", device.Method, strings.Join(duo.PossibleMethods, ", ")), messageMFAValidationFailed) + if !utils.IsStringInSlice(bodyJSON.Method, duo.PossibleMethods) { + ctx.Error(fmt.Errorf("unknown method '%s', it should be one of %s", bodyJSON.Method, strings.Join(duo.PossibleMethods, ", ")), messageMFAValidationFailed) return } - userSession := ctx.GetSession() - ctx.Logger.Debugf("Save new preferred Duo device and method of user %s to %s using %s", userSession.Username, device.Device, device.Method) - err = ctx.Providers.StorageProvider.SavePreferredDuoDevice(ctx, model.DuoDevice{Username: userSession.Username, Device: device.Device, Method: device.Method}) + if userSession, err = ctx.GetSession(); err != nil { + ctx.Error(err, messageMFAValidationFailed) + return + } + + ctx.Logger.Debugf("Save new preferred Duo device and method of user %s to %s using %s", userSession.Username, bodyJSON.Device, bodyJSON.Method) + err = ctx.Providers.StorageProvider.SavePreferredDuoDevice(ctx, model.DuoDevice{Username: userSession.Username, Device: bodyJSON.Device, Method: bodyJSON.Method}) if err != nil { - ctx.Error(fmt.Errorf("unable to save new preferred Duo device and method: %s", err), messageMFAValidationFailed) + ctx.Error(fmt.Errorf("unable to save new preferred Duo device and method: %w", err), messageMFAValidationFailed) return } ctx.ReplyOK() } -// SecondFactorDuoDeviceDelete deletes the useres preferred Duo device and method. -func SecondFactorDuoDeviceDelete(ctx *middlewares.AutheliaCtx) { - userSession := ctx.GetSession() +// DuoDeviceDELETE deletes the useres preferred Duo device and method. +func DuoDeviceDELETE(ctx *middlewares.AutheliaCtx) { + var ( + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Error(fmt.Errorf("unable to get session to delete preferred Duo device and method: %w", err), messageMFAValidationFailed) + return + } + ctx.Logger.Debugf("Deleting preferred Duo device and method of user %s", userSession.Username) - err := ctx.Providers.StorageProvider.DeletePreferredDuoDevice(ctx, userSession.Username) - if err != nil { - ctx.Error(fmt.Errorf("unable to delete preferred Duo device and method: %s", err), messageMFAValidationFailed) + if err = ctx.Providers.StorageProvider.DeletePreferredDuoDevice(ctx, userSession.Username); err != nil { + ctx.Error(fmt.Errorf("unable to delete preferred Duo device and method: %w", err), messageMFAValidationFailed) return } diff --git a/internal/handlers/handler_register_duo_device_test.go b/internal/handlers/handler_register_duo_device_test.go index cd77e8a39..f16eead9d 100644 --- a/internal/handlers/handler_register_duo_device_test.go +++ b/internal/handlers/handler_register_duo_device_test.go @@ -13,6 +13,7 @@ import ( "github.com/authelia/authelia/v4/internal/duo" "github.com/authelia/authelia/v4/internal/mocks" "github.com/authelia/authelia/v4/internal/model" + "github.com/authelia/authelia/v4/internal/session" ) type RegisterDuoDeviceSuite struct { @@ -22,10 +23,11 @@ type RegisterDuoDeviceSuite struct { func (s *RegisterDuoDeviceSuite) SetupTest() { s.mock = mocks.NewMockAutheliaCtx(s.T()) - userSession := s.mock.Ctx.GetSession() - userSession.Username = testUsername - err := s.mock.Ctx.SaveSession(userSession) + userSession, err := s.mock.Ctx.GetSession() s.Assert().NoError(err) + + userSession.Username = testUsername + s.NoError(s.mock.Ctx.SaveSession(userSession)) } func (s *RegisterDuoDeviceSuite) TearDownTest() { @@ -38,7 +40,7 @@ func (s *RegisterDuoDeviceSuite) TestShouldCallDuoAPIAndFail() { values := url.Values{} values.Set("username", "john") - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(nil, fmt.Errorf("Connnection error")) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(nil, fmt.Errorf("Connnection error")) DuoDevicesGET(duoMock)(s.mock.Ctx) @@ -68,7 +70,7 @@ func (s *RegisterDuoDeviceSuite) TestShouldRespondWithSelection() { response.Result = auth response.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&response, nil) DuoDevicesGET(duoMock)(s.mock.Ctx) @@ -84,7 +86,7 @@ func (s *RegisterDuoDeviceSuite) TestShouldRespondWithAllowOnBypass() { response := duo.PreAuthResponse{} response.Result = allow - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&response, nil) DuoDevicesGET(duoMock)(s.mock.Ctx) @@ -103,7 +105,7 @@ func (s *RegisterDuoDeviceSuite) TestShouldRespondWithEnroll() { response.Result = enroll response.EnrollPortalURL = enrollURL - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&response, nil) DuoDevicesGET(duoMock)(s.mock.Ctx) @@ -119,7 +121,7 @@ func (s *RegisterDuoDeviceSuite) TestShouldRespondWithDeny() { response := duo.PreAuthResponse{} response.Result = deny - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&response, nil) DuoDevicesGET(duoMock)(s.mock.Ctx) diff --git a/internal/handlers/handler_register_totp.go b/internal/handlers/handler_register_totp.go index 160a32961..91fd20072 100644 --- a/internal/handlers/handler_register_totp.go +++ b/internal/handlers/handler_register_totp.go @@ -9,8 +9,12 @@ import ( ) // identityRetrieverFromSession retriever computing the identity from the cookie session. -func identityRetrieverFromSession(ctx *middlewares.AutheliaCtx) (*session.Identity, error) { - userSession := ctx.GetSession() +func identityRetrieverFromSession(ctx *middlewares.AutheliaCtx) (identity *session.Identity, err error) { + var userSession session.UserSession + + if userSession, err = ctx.GetSession(); err != nil { + return nil, fmt.Errorf("error retrieving user session for request: %w", err) + } if len(userSession.Emails) == 0 { return nil, fmt.Errorf("user %s does not have any email address", userSession.Username) @@ -24,7 +28,9 @@ func identityRetrieverFromSession(ctx *middlewares.AutheliaCtx) (*session.Identi } func isTokenUserValidFor2FARegistration(ctx *middlewares.AutheliaCtx, username string) bool { - return ctx.GetSession().Username == username + userSession, err := ctx.GetSession() + + return err == nil && userSession.Username == username } // TOTPIdentityStart the handler for initiating the identity validation. diff --git a/internal/handlers/handler_register_webauthn.go b/internal/handlers/handler_register_webauthn.go index bc0f001b4..72798f138 100644 --- a/internal/handlers/handler_register_webauthn.go +++ b/internal/handlers/handler_register_webauthn.go @@ -11,6 +11,7 @@ import ( "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/regulation" + "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/storage" ) @@ -34,12 +35,19 @@ var WebauthnIdentityFinish = middlewares.IdentityVerificationFinish( // SecondFactorWebauthnAttestationGET returns the attestation challenge from the server. func SecondFactorWebauthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string) { var ( - w *webauthn.WebAuthn - user *model.WebauthnUser - err error + w *webauthn.WebAuthn + user *model.WebauthnUser + userSession session.UserSession + err error ) - userSession := ctx.GetSession() + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s attestation challenge", regulation.AuthTypeWebauthn) + + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } if w, err = newWebauthn(ctx); err != nil { ctx.Logger.Errorf("Unable to create %s attestation challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) @@ -96,12 +104,20 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) { w *webauthn.WebAuthn user *model.WebauthnUser + userSession session.UserSession + attestationResponse *protocol.ParsedCredentialCreationData credential *webauthn.Credential postData *requestPostData ) - userSession := ctx.GetSession() + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s attestation response", regulation.AuthTypeWebauthn) + + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } if userSession.Webauthn == nil { ctx.Logger.Errorf("Webauthn session data is not present in order to handle attestation for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", userSession.Username) diff --git a/internal/handlers/handler_reset_password_step1.go b/internal/handlers/handler_reset_password_step1.go index 9c8704410..dc0935ec8 100644 --- a/internal/handlers/handler_reset_password_step1.go +++ b/internal/handlers/handler_reset_password_step1.go @@ -45,12 +45,19 @@ var ResetPasswordIdentityStart = middlewares.IdentityVerificationStart(middlewar }, middlewares.TimingAttackDelay(10, 250, 85, time.Millisecond*500, false)) func resetPasswordIdentityFinish(ctx *middlewares.AutheliaCtx, username string) { - userSession := ctx.GetSession() + var ( + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.Errorf("Unable to get session to clear password reset flag in session for user %s: %s", userSession.Username, err) + } + // TODO(c.michaud): use JWT tokens to expire the request in only few seconds for better security. userSession.PasswordResetUsername = &username - err := ctx.SaveSession(userSession) - if err != nil { + if err = ctx.SaveSession(userSession); err != nil { ctx.Logger.Errorf("Unable to clear password reset flag in session for user %s: %s", userSession.Username, err) } diff --git a/internal/handlers/handler_reset_password_step2.go b/internal/handlers/handler_reset_password_step2.go index 9683bfb4f..795364a12 100644 --- a/internal/handlers/handler_reset_password_step2.go +++ b/internal/handlers/handler_reset_password_step2.go @@ -4,13 +4,22 @@ import ( "fmt" "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/templates" "github.com/authelia/authelia/v4/internal/utils" ) // ResetPasswordPOST handler for resetting passwords. func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { - userSession := ctx.GetSession() + var ( + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Error(fmt.Errorf("error occurred retrieving session for user: %w", err), messageUnableToResetPassword) + return + } // Those checks unsure that the identity verification process has been initiated and completed successfully // otherwise PasswordReset would not be set to true. We can improve the security of this check by making the @@ -23,9 +32,8 @@ func ResetPasswordPOST(ctx *middlewares.AutheliaCtx) { username := *userSession.PasswordResetUsername var requestBody resetPasswordStep2RequestBody - err := ctx.ParseBody(&requestBody) - if err != nil { + if err = ctx.ParseBody(&requestBody); err != nil { ctx.Error(err, messageUnableToResetPassword) return } diff --git a/internal/handlers/handler_sign_duo.go b/internal/handlers/handler_sign_duo.go index 609edca76..f3bb2339a 100644 --- a/internal/handlers/handler_sign_duo.go +++ b/internal/handlers/handler_sign_duo.go @@ -18,9 +18,12 @@ func DuoPOST(duoAPI duo.API) middlewares.RequestHandler { var ( bodyJSON = &bodySignDuoRequest{} device, method string + + userSession session.UserSession + err error ) - if err := ctx.ParseBody(bodyJSON); err != nil { + if err = ctx.ParseBody(bodyJSON); err != nil { ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeDuo, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -28,7 +31,11 @@ func DuoPOST(duoAPI duo.API) middlewares.RequestHandler { return } - userSession := ctx.GetSession() + if userSession, err = ctx.GetSession(); err != nil { + ctx.Error(fmt.Errorf("error occurred retrieving user session: %w", err), messageMFAValidationFailed) + return + } + remoteIP := ctx.RemoteIP().String() duoDevice, err := ctx.Providers.StorageProvider.LoadPreferredDuoDevice(ctx, userSession.Username) @@ -61,7 +68,7 @@ func DuoPOST(duoAPI duo.API) middlewares.RequestHandler { return } - authResponse, err := duoAPI.AuthCall(ctx, values) + authResponse, err := duoAPI.AuthCall(ctx, &userSession, values) if err != nil { ctx.Logger.Errorf("Failed to perform Duo Auth Call for user '%s': %+v", userSession.Username, err) @@ -85,13 +92,13 @@ func DuoPOST(duoAPI duo.API) middlewares.RequestHandler { return } - HandleAllow(ctx, bodyJSON) + HandleAllow(ctx, &userSession, bodyJSON) } } // HandleInitialDeviceSelection handler for retrieving all available devices. func HandleInitialDeviceSelection(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, bodyJSON *bodySignDuoRequest) (device string, method string, err error) { - result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI) + result, message, devices, enrollURL, err := DuoPreAuth(ctx, userSession, duoAPI) if err != nil { ctx.Logger.Errorf("Failed to perform Duo PreAuth for user '%s': %+v", userSession.Username, err) @@ -119,7 +126,7 @@ func HandleInitialDeviceSelection(ctx *middlewares.AutheliaCtx, userSession *ses return "", "", nil case allow: ctx.Logger.Debugf("Duo authentication was bypassed for user: %s", userSession.Username) - HandleAllow(ctx, bodyJSON) + HandleAllow(ctx, userSession, bodyJSON) return "", "", nil case auth: @@ -136,7 +143,7 @@ func HandleInitialDeviceSelection(ctx *middlewares.AutheliaCtx, userSession *ses // HandlePreferredDeviceCheck handler to check if the saved device and method is still valid. func HandlePreferredDeviceCheck(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, duoAPI duo.API, device string, method string, bodyJSON *bodySignDuoRequest) (string, string, error) { - result, message, devices, enrollURL, err := DuoPreAuth(ctx, duoAPI) + result, message, devices, enrollURL, err := DuoPreAuth(ctx, userSession, duoAPI) if err != nil { ctx.Logger.Errorf("Failed to perform Duo PreAuth for user '%s': %+v", userSession.Username, err) @@ -165,7 +172,7 @@ func HandlePreferredDeviceCheck(ctx *middlewares.AutheliaCtx, userSession *sessi return "", "", nil case allow: ctx.Logger.Debugf("Duo authentication was bypassed for user: %s", userSession.Username) - HandleAllow(ctx, bodyJSON) + HandleAllow(ctx, userSession, bodyJSON) return "", "", nil case auth: @@ -243,11 +250,12 @@ func HandleAutoSelection(ctx *middlewares.AutheliaCtx, devices []DuoDevice, user } // HandleAllow handler for successful logins. -func HandleAllow(ctx *middlewares.AutheliaCtx, bodyJSON *bodySignDuoRequest) { - userSession := ctx.GetSession() +func HandleAllow(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, bodyJSON *bodySignDuoRequest) { + var ( + err error + ) - err := ctx.RegenerateSession() - if err != nil { + if err = ctx.RegenerateSession(); err != nil { ctx.Logger.Errorf(logFmtErrSessionRegenerate, regulation.AuthTypeDuo, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -257,8 +265,7 @@ func HandleAllow(ctx *middlewares.AutheliaCtx, bodyJSON *bodySignDuoRequest) { userSession.SetTwoFactorDuo(ctx.Clock.Now()) - err = ctx.SaveSession(userSession) - if err != nil { + if err = ctx.SaveSession(*userSession); err != nil { ctx.Logger.Errorf(logFmtErrSessionSave, "authentication time", regulation.AuthTypeTOTP, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) diff --git a/internal/handlers/handler_sign_duo_test.go b/internal/handlers/handler_sign_duo_test.go index 70765bf71..284dac9ef 100644 --- a/internal/handlers/handler_sign_duo_test.go +++ b/internal/handlers/handler_sign_duo_test.go @@ -10,7 +10,6 @@ import ( "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/authelia/authelia/v4/internal/configuration/schema" @@ -18,6 +17,7 @@ import ( "github.com/authelia/authelia/v4/internal/mocks" "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/regulation" + "github.com/authelia/authelia/v4/internal/session" ) type SecondFactorDuoPostSuite struct { @@ -27,10 +27,13 @@ type SecondFactorDuoPostSuite struct { func (s *SecondFactorDuoPostSuite) SetupTest() { s.mock = mocks.NewMockAutheliaCtx(s.T()) - userSession := s.mock.Ctx.GetSession() + + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + userSession.Username = testUsername - err := s.mock.Ctx.SaveSession(userSession) - require.NoError(s.T(), err) + + s.Assert().NoError(s.mock.Ctx.SaveSession(userSession)) } func (s *SecondFactorDuoPostSuite) TearDownTest() { @@ -53,7 +56,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldEnroll() { preAuthResponse.Result = enroll preAuthResponse.EnrollPortalURL = enrollURL - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{}) s.Require().NoError(err) @@ -84,7 +87,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldAutoSelect() { preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) s.mock.StorageMock.EXPECT(). SavePreferredDuoDevice(s.mock.Ctx, model.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}). @@ -112,7 +115,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldAutoSelect() { authResponse := duo.AuthResponse{} authResponse.Result = allow - duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&authResponse, nil) + duoMock.EXPECT().AuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&authResponse, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{TargetURL: "https://target.example.com"}) s.Require().NoError(err) @@ -135,7 +138,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldDenyAutoSelect() { preAuthResponse := duo.PreAuthResponse{} preAuthResponse.Result = deny - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) values = url.Values{} values.Set("username", "john") @@ -161,7 +164,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldFailAutoSelect() { LoadPreferredDuoDevice(s.mock.Ctx, "john"). Return(nil, errors.New("no Duo device and method saved")) - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error")) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Any()).Return(nil, fmt.Errorf("Connnection error")) bodyBytes, err := json.Marshal(bodySignDuoRequest{TargetURL: "https://target.example.com"}) s.Require().NoError(err) @@ -188,7 +191,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldDeleteOldDeviceAndEnroll() { preAuthResponse.Result = enroll preAuthResponse.EnrollPortalURL = enrollURL - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) s.mock.StorageMock.EXPECT().DeletePreferredDuoDevice(s.mock.Ctx, "john").Return(nil) @@ -222,7 +225,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldDeleteOldDeviceAndCallPreauthAPIWit preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) s.mock.StorageMock.EXPECT().DeletePreferredDuoDevice(s.mock.Ctx, "john").Return(nil) @@ -262,7 +265,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldUseOldDeviceAndSelect() { preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{}) s.Require().NoError(err) @@ -302,7 +305,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldUseInvalidMethodAndAutoSelect() { preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) s.mock.StorageMock.EXPECT(). SavePreferredDuoDevice(s.mock.Ctx, model.DuoDevice{Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}). @@ -318,7 +321,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldUseInvalidMethodAndAutoSelect() { authResponse := duo.AuthResponse{} authResponse.Result = allow - duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&authResponse, nil) + duoMock.EXPECT().AuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&authResponse, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{TargetURL: "https://target.example.com"}) s.Require().NoError(err) @@ -341,7 +344,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndAllowAccess() { preAuthResponse := duo.PreAuthResponse{} preAuthResponse.Result = allow - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{TargetURL: "https://target.example.com"}) s.Require().NoError(err) @@ -365,7 +368,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndDenyAccess() { preAuthResponse := duo.PreAuthResponse{} preAuthResponse.Result = deny - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) values = url.Values{} values.Set("username", "john") @@ -389,7 +392,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoPreauthAPIAndFail() { LoadPreferredDuoDevice(s.mock.Ctx, "john"). Return(&model.DuoDevice{ID: 1, Username: "john", Device: "12345ABCDEFGHIJ67890", Method: "push"}, nil) - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error")) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Any()).Return(nil, fmt.Errorf("Connnection error")) bodyBytes, err := json.Marshal(bodySignDuoRequest{}) s.Require().NoError(err) @@ -430,7 +433,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() { preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) values = url.Values{} values.Set("username", "john") @@ -441,7 +444,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndDenyAccess() { response := duo.AuthResponse{} response.Result = deny - duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&response, nil) + duoMock.EXPECT().AuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&response, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{}) s.Require().NoError(err) @@ -470,9 +473,9 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() { preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) - duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(nil, fmt.Errorf("Connnection error")) + duoMock.EXPECT().AuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Any()).Return(nil, fmt.Errorf("Connnection error")) bodyBytes, err := json.Marshal(bodySignDuoRequest{}) s.Require().NoError(err) @@ -513,12 +516,12 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() { preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) response := duo.AuthResponse{} response.Result = allow - duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil) + duoMock.EXPECT().AuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Any()).Return(&response, nil) s.mock.Ctx.Configuration.DefaultRedirectionURL = testRedirectionURL @@ -562,12 +565,12 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() { preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) response := duo.AuthResponse{} response.Result = allow - duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil) + duoMock.EXPECT().AuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Any()).Return(&response, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{}) s.Require().NoError(err) @@ -619,12 +622,12 @@ func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() { preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) response := duo.AuthResponse{} response.Result = allow - duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil) + duoMock.EXPECT().AuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Any()).Return(&response, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{ TargetURL: "https://example.com", @@ -668,12 +671,12 @@ func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() { preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) response := duo.AuthResponse{} response.Result = allow - duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil) + duoMock.EXPECT().AuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Any()).Return(&response, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{ TargetURL: "http://example.com", @@ -715,12 +718,12 @@ func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessi preAuthResponse.Result = auth preAuthResponse.Devices = duoDevices - duoMock.EXPECT().PreAuthCall(s.mock.Ctx, gomock.Eq(values)).Return(&preAuthResponse, nil) + duoMock.EXPECT().PreAuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Eq(values)).Return(&preAuthResponse, nil) response := duo.AuthResponse{} response.Result = allow - duoMock.EXPECT().AuthCall(s.mock.Ctx, gomock.Any()).Return(&response, nil) + duoMock.EXPECT().AuthCall(s.mock.Ctx, &session.UserSession{CookieDomain: "example.com", Username: "john"}, gomock.Any()).Return(&response, nil) bodyBytes, err := json.Marshal(bodySignDuoRequest{ TargetURL: "http://example.com", @@ -734,7 +737,7 @@ func (s *SecondFactorDuoPostSuite) TestShouldRegenerateSessionForPreventingSessi DuoPOST(duoMock)(s.mock.Ctx) s.mock.Assert200OK(s.T(), nil) - s.Assert().NotEqual( + s.NotEqual( res[0][1], string(s.mock.Ctx.Request.Header.Cookie("authelia_session"))) } diff --git a/internal/handlers/handler_sign_totp.go b/internal/handlers/handler_sign_totp.go index 1825dcb84..9afebec7d 100644 --- a/internal/handlers/handler_sign_totp.go +++ b/internal/handlers/handler_sign_totp.go @@ -3,13 +3,19 @@ package handlers import ( "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/regulation" + "github.com/authelia/authelia/v4/internal/session" ) // TimeBasedOneTimePasswordPOST validate the TOTP passcode provided by the user. func TimeBasedOneTimePasswordPOST(ctx *middlewares.AutheliaCtx) { bodyJSON := bodySignTOTPRequest{} - if err := ctx.ParseBody(&bodyJSON); err != nil { + var ( + userSession session.UserSession + err error + ) + + if err = ctx.ParseBody(&bodyJSON); err != nil { ctx.Logger.Errorf(logFmtErrParseRequestBody, regulation.AuthTypeTOTP, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -17,7 +23,13 @@ func TimeBasedOneTimePasswordPOST(ctx *middlewares.AutheliaCtx) { return } - userSession := ctx.GetSession() + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } config, err := ctx.Providers.StorageProvider.LoadTOTPConfiguration(ctx, userSession.Username) if err != nil { diff --git a/internal/handlers/handler_sign_totp_test.go b/internal/handlers/handler_sign_totp_test.go index b4cd12086..c1c6ea3ab 100644 --- a/internal/handlers/handler_sign_totp_test.go +++ b/internal/handlers/handler_sign_totp_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/authelia/authelia/v4/internal/configuration/schema" @@ -24,10 +23,11 @@ type HandlerSignTOTPSuite struct { func (s *HandlerSignTOTPSuite) SetupTest() { s.mock = mocks.NewMockAutheliaCtx(s.T()) - userSession := s.mock.Ctx.GetSession() + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + userSession.Username = testUsername - err := s.mock.Ctx.SaveSession(userSession) - require.NoError(s.T(), err) + s.Assert().NoError(s.mock.Ctx.SaveSession(userSession)) } func (s *HandlerSignTOTPSuite) TearDownTest() { @@ -266,7 +266,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRegenerateSessionForPreventingSessionFi TimeBasedOneTimePasswordPOST(s.mock.Ctx) s.mock.Assert200OK(s.T(), nil) - s.Assert().NotEqual( + s.NotEqual( res[0][1], string(s.mock.Ctx.Request.Header.Cookie("authelia_session"))) } diff --git a/internal/handlers/handler_sign_webauthn.go b/internal/handlers/handler_sign_webauthn.go index 571f2d8cf..da7b88f82 100644 --- a/internal/handlers/handler_sign_webauthn.go +++ b/internal/handlers/handler_sign_webauthn.go @@ -9,17 +9,25 @@ import ( "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/regulation" + "github.com/authelia/authelia/v4/internal/session" ) // WebauthnAssertionGET handler starts the assertion ceremony. func WebauthnAssertionGET(ctx *middlewares.AutheliaCtx) { var ( - w *webauthn.WebAuthn - user *model.WebauthnUser - err error + w *webauthn.WebAuthn + user *model.WebauthnUser + userSession session.UserSession + err error ) - userSession := ctx.GetSession() + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } if w, err = newWebauthn(ctx); err != nil { ctx.Logger.Errorf("Unable to configure %s during assertion challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) @@ -79,8 +87,12 @@ func WebauthnAssertionGET(ctx *middlewares.AutheliaCtx) { } // WebauthnAssertionPOST handler completes the assertion ceremony after verifying the challenge. +// +//nolint:gocyclo func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { var ( + userSession session.UserSession + err error w *webauthn.WebAuthn @@ -95,7 +107,13 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { return } - userSession := ctx.GetSession() + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + respondUnauthorized(ctx, messageMFAValidationFailed) + + return + } if userSession.Webauthn == nil { ctx.Logger.Errorf("Webauthn session data is not present in order to handle assertion for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", userSession.Username) diff --git a/internal/handlers/handler_state.go b/internal/handlers/handler_state.go index 086750fea..c537d9b65 100644 --- a/internal/handlers/handler_state.go +++ b/internal/handlers/handler_state.go @@ -2,19 +2,31 @@ package handlers import ( "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/session" ) // StateGET is the handler serving the user state. func StateGET(ctx *middlewares.AutheliaCtx) { - userSession := ctx.GetSession() + var ( + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + ctx.ReplyForbidden() + + return + } + stateResponse := StateResponse{ Username: userSession.Username, AuthenticationLevel: userSession.AuthenticationLevel, DefaultRedirectionURL: ctx.Configuration.DefaultRedirectionURL, } - err := ctx.SetJSONBody(stateResponse) - if err != nil { + if err = ctx.SetJSONBody(stateResponse); err != nil { ctx.Logger.Errorf("Unable to set state response in body: %s", err) } } diff --git a/internal/handlers/handler_state_test.go b/internal/handlers/handler_state_test.go index 2605be470..20baf25ce 100644 --- a/internal/handlers/handler_state_test.go +++ b/internal/handlers/handler_state_test.go @@ -27,10 +27,11 @@ func (s *StateGetSuite) TearDownTest() { } func (s *StateGetSuite) TestShouldReturnUsernameFromSession() { - userSession := s.mock.Ctx.GetSession() + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + userSession.Username = "username" - err := s.mock.Ctx.SaveSession(userSession) - require.NoError(s.T(), err) + s.Assert().NoError(s.mock.Ctx.SaveSession(userSession)) StateGET(s.mock.Ctx) @@ -57,9 +58,11 @@ func (s *StateGetSuite) TestShouldReturnUsernameFromSession() { } func (s *StateGetSuite) TestShouldReturnAuthenticationLevelFromSession() { - userSession := s.mock.Ctx.GetSession() + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + userSession.AuthenticationLevel = authentication.OneFactor - err := s.mock.Ctx.SaveSession(userSession) + s.Assert().NoError(s.mock.Ctx.SaveSession(userSession)) require.NoError(s.T(), err) StateGET(s.mock.Ctx) diff --git a/internal/handlers/handler_user_info.go b/internal/handlers/handler_user_info.go index 6e4f5dc0d..54aa056db 100644 --- a/internal/handlers/handler_user_info.go +++ b/internal/handlers/handler_user_info.go @@ -8,18 +8,26 @@ import ( "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" + "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/utils" ) // UserInfoPOST handles setting up info for users if necessary when they login. func UserInfoPOST(ctx *middlewares.AutheliaCtx) { - userSession := ctx.GetSession() - var ( - userInfo model.UserInfo - err error + userSession session.UserSession + userInfo model.UserInfo + err error ) + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + ctx.ReplyForbidden() + + return + } + if _, err = ctx.Providers.StorageProvider.LoadPreferred2FAMethod(ctx, userSession.Username); err != nil { if errors.Is(err, sql.ErrNoRows) { if err = ctx.Providers.StorageProvider.SavePreferred2FAMethod(ctx, userSession.Username, ""); err != nil { @@ -56,7 +64,18 @@ func UserInfoPOST(ctx *middlewares.AutheliaCtx) { // UserInfoGET get the info related to the user identified by the session. func UserInfoGET(ctx *middlewares.AutheliaCtx) { - userSession := ctx.GetSession() + var ( + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + ctx.ReplyForbidden() + + return + } userInfo, err := ctx.Providers.StorageProvider.LoadUserInfo(ctx, userSession.Username) if err != nil { @@ -74,10 +93,22 @@ func UserInfoGET(ctx *middlewares.AutheliaCtx) { // MethodPreferencePOST update the user preferences regarding 2FA method. func MethodPreferencePOST(ctx *middlewares.AutheliaCtx) { - bodyJSON := bodyPreferred2FAMethod{} + var ( + bodyJSON bodyPreferred2FAMethod - err := ctx.ParseBody(&bodyJSON) - if err != nil { + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + ctx.Error(err, messageOperationFailed) + + return + } + + if err = ctx.ParseBody(&bodyJSON); err != nil { ctx.Error(err, messageOperationFailed) return } @@ -87,7 +118,6 @@ func MethodPreferencePOST(ctx *middlewares.AutheliaCtx) { return } - userSession := ctx.GetSession() ctx.Logger.Debugf("Save new preferred 2FA method of user %s to %s", userSession.Username, bodyJSON.Method) err = ctx.Providers.StorageProvider.SavePreferred2FAMethod(ctx, userSession.Username, bodyJSON.Method) diff --git a/internal/handlers/handler_user_info_test.go b/internal/handlers/handler_user_info_test.go index 1fb8e2a83..251f7e247 100644 --- a/internal/handlers/handler_user_info_test.go +++ b/internal/handlers/handler_user_info_test.go @@ -9,7 +9,6 @@ import ( "github.com/golang/mock/gomock" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/authelia/authelia/v4/internal/configuration/schema" @@ -24,12 +23,12 @@ type FetchSuite struct { func (s *FetchSuite) SetupTest() { s.mock = mocks.NewMockAutheliaCtx(s.T()) - // Set the initial user session. - userSession := s.mock.Ctx.GetSession() + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + userSession.Username = testUsername userSession.AuthenticationLevel = 1 - err := s.mock.Ctx.SaveSession(userSession) - require.NoError(s.T(), err) + s.Assert().NoError(s.mock.Ctx.SaveSession(userSession)) } func (s *FetchSuite) TearDownTest() { @@ -101,12 +100,12 @@ func TestUserInfoEndpoint_SetCorrectMethod(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) - // Set the initial user session. - userSession := mock.Ctx.GetSession() + userSession, err := mock.Ctx.GetSession() + assert.NoError(t, err) + userSession.Username = testUsername userSession.AuthenticationLevel = 1 - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) + assert.NoError(t, mock.Ctx.SaveSession(userSession)) mock.StorageMock. EXPECT(). @@ -266,12 +265,12 @@ func TestUserInfoEndpoint_SetDefaultMethod(t *testing.T) { mock.Ctx.Configuration.Session = sessionConfig } - // Set the initial user session. - userSession := mock.Ctx.GetSession() + userSession, err := mock.Ctx.GetSession() + assert.NoError(t, err) + userSession.Username = testUsername userSession.AuthenticationLevel = 1 - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) + assert.NoError(t, mock.Ctx.SaveSession(userSession)) if resp.db.Method == "" { gomock.InOrder( @@ -372,12 +371,12 @@ type SaveSuite struct { func (s *SaveSuite) SetupTest() { s.mock = mocks.NewMockAutheliaCtx(s.T()) - // Set the initial user session. - userSession := s.mock.Ctx.GetSession() + userSession, err := s.mock.Ctx.GetSession() + s.Assert().NoError(err) + userSession.Username = testUsername userSession.AuthenticationLevel = 1 - err := s.mock.Ctx.SaveSession(userSession) - require.NoError(s.T(), err) + s.Assert().NoError(s.mock.Ctx.SaveSession(userSession)) } func (s *SaveSuite) TearDownTest() { diff --git a/internal/handlers/handler_user_totp.go b/internal/handlers/handler_user_totp.go index ee80cc7c8..be3293ae7 100644 --- a/internal/handlers/handler_user_totp.go +++ b/internal/handlers/handler_user_totp.go @@ -6,15 +6,29 @@ import ( "github.com/valyala/fasthttp" "github.com/authelia/authelia/v4/internal/middlewares" + "github.com/authelia/authelia/v4/internal/model" + "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/storage" ) // UserTOTPInfoGET returns the users TOTP configuration. func UserTOTPInfoGET(ctx *middlewares.AutheliaCtx) { - userSession := ctx.GetSession() + var ( + userSession session.UserSession + err error + ) - config, err := ctx.Providers.StorageProvider.LoadTOTPConfiguration(ctx, userSession.Username) - if err != nil { + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + ctx.ReplyForbidden() + + return + } + + var config *model.TOTPConfiguration + + if config, err = ctx.Providers.StorageProvider.LoadTOTPConfiguration(ctx, userSession.Username); err != nil { if errors.Is(err, storage.ErrNoTOTPConfiguration) { ctx.SetStatusCode(fasthttp.StatusNotFound) ctx.SetJSONError("Could not find TOTP Configuration for user.") diff --git a/internal/handlers/handler_verify.go b/internal/handlers/handler_verify.go deleted file mode 100644 index 723a05c69..000000000 --- a/internal/handlers/handler_verify.go +++ /dev/null @@ -1,515 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/base64" - "fmt" - "net" - "net/url" - "strings" - "time" - - "github.com/valyala/fasthttp" - - "github.com/authelia/authelia/v4/internal/authentication" - "github.com/authelia/authelia/v4/internal/authorization" - "github.com/authelia/authelia/v4/internal/configuration/schema" - "github.com/authelia/authelia/v4/internal/middlewares" - "github.com/authelia/authelia/v4/internal/session" - "github.com/authelia/authelia/v4/internal/utils" -) - -func isSchemeWSS(url *url.URL) bool { - return url.Scheme == "wss" -} - -// parseBasicAuth parses an HTTP Basic Authentication string. -// "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true). -func parseBasicAuth(header []byte, auth string) (username, password string, err error) { - if !strings.HasPrefix(auth, authPrefix) { - return "", "", fmt.Errorf("%s prefix not found in %s header", strings.Trim(authPrefix, " "), header) - } - - c, err := base64.StdEncoding.DecodeString(auth[len(authPrefix):]) - if err != nil { - return "", "", err - } - - cs := string(c) - s := strings.IndexByte(cs, ':') - - if s < 0 { - return "", "", fmt.Errorf("format of %s header must be user:password", header) - } - - return cs[:s], cs[s+1:], nil -} - -// isTargetURLAuthorized check whether the given user is authorized to access the resource. -func isTargetURLAuthorized(authorizer *authorization.Authorizer, targetURL *url.URL, - username string, userGroups []string, clientIP net.IP, method []byte, authLevel authentication.Level) authorizationMatching { - hasSubject, level := authorizer.GetRequiredLevel( - authorization.Subject{ - Username: username, - Groups: userGroups, - IP: clientIP, - }, - authorization.NewObjectRaw(targetURL, method)) - - switch { - case level == authorization.Bypass: - return Authorized - case level == authorization.Denied && (username != "" || !hasSubject): - // If the user is not anonymous, it means that we went through - // all the rules related to that user and knowing who he is we can - // deduce the access is forbidden - // For anonymous users though, we check that the matched rule has no subject - // if matched rule has not subject then this rule applies to all users including anonymous. - return Forbidden - case level == authorization.OneFactor && authLevel >= authentication.OneFactor, - level == authorization.TwoFactor && authLevel >= authentication.TwoFactor: - return Authorized - } - - return NotAuthorized -} - -// verifyBasicAuth verify that the provided username and password are correct and -// that the user is authorized to target the resource. -func verifyBasicAuth(ctx *middlewares.AutheliaCtx, header, auth []byte) (username, name string, groups, emails []string, authLevel authentication.Level, err error) { - username, password, err := parseBasicAuth(header, string(auth)) - - if err != nil { - return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("unable to parse content of %s header: %s", header, err) - } - - authenticated, err := ctx.Providers.UserProvider.CheckUserPassword(username, password) - - if err != nil { - return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("unable to check credentials extracted from %s header: %w", header, err) - } - - // If the user is not correctly authenticated, send a 401. - if !authenticated { - // Request Basic Authentication otherwise. - return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("user %s is not authenticated", username) - } - - details, err := ctx.Providers.UserProvider.GetDetails(username) - - if err != nil { - return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("unable to retrieve details of user %s: %s", username, err) - } - - return username, details.DisplayName, details.Groups, details.Emails, authentication.OneFactor, nil -} - -// setForwardedHeaders set the forwarded User, Groups, Name and Email headers. -func setForwardedHeaders(headers *fasthttp.ResponseHeader, username, name string, groups, emails []string) { - if username != "" { - headers.SetBytesK(headerRemoteUser, username) - headers.SetBytesK(headerRemoteGroups, strings.Join(groups, ",")) - headers.SetBytesK(headerRemoteName, name) - - if emails != nil { - headers.SetBytesK(headerRemoteEmail, emails[0]) - } else { - headers.SetBytesK(headerRemoteEmail, "") - } - } -} - -func isSessionInactiveTooLong(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, isUserAnonymous bool) (isInactiveTooLong bool) { - domainSession, err := ctx.GetSessionProvider() - if err != nil { - return false - } - - if userSession.KeepMeLoggedIn || isUserAnonymous || int64(domainSession.Config.Inactivity.Seconds()) == 0 { - return false - } - - isInactiveTooLong = time.Unix(userSession.LastActivity, 0).Add(domainSession.Config.Inactivity).Before(ctx.Clock.Now()) - - ctx.Logger.Tracef("Inactivity report for user '%s'. Current Time: %d, Last Activity: %d, Maximum Inactivity: %d.", userSession.Username, ctx.Clock.Now().Unix(), userSession.LastActivity, int(domainSession.Config.Inactivity.Seconds())) - - return isInactiveTooLong -} - -// verifySessionCookie verifies if a user is identified by a cookie. -func verifySessionCookie(ctx *middlewares.AutheliaCtx, targetURL *url.URL, userSession *session.UserSession, refreshProfile bool, - refreshProfileInterval time.Duration) (username, name string, groups, emails []string, authLevel authentication.Level, err error) { - // No username in the session means the user is anonymous. - isUserAnonymous := userSession.IsAnonymous() - - if isUserAnonymous && userSession.AuthenticationLevel != authentication.NotAuthenticated { - return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("an anonymous user cannot be authenticated (this might be the sign of a security compromise)") - } - - if isSessionInactiveTooLong(ctx, userSession, isUserAnonymous) { - // Destroy the session a new one will be regenerated on next request. - if err = ctx.DestroySession(); err != nil { - return "", "", nil, nil, authentication.NotAuthenticated, fmt.Errorf("unable to destroy session for user '%s' after the session has been inactive too long: %w", userSession.Username, err) - } - - ctx.Logger.Warnf("Session destroyed for user '%s' after exceeding configured session inactivity and not being marked as remembered", userSession.Username) - - return "", "", nil, nil, authentication.NotAuthenticated, nil - } - - if err = verifySessionHasUpToDateProfile(ctx, targetURL, userSession, refreshProfile, refreshProfileInterval); err != nil { - if err == authentication.ErrUserNotFound { - if err = ctx.DestroySession(); err != nil { - ctx.Logger.Errorf("Unable to destroy user session after provider refresh didn't find the user: %v", err) - } - - return userSession.Username, userSession.DisplayName, userSession.Groups, userSession.Emails, authentication.NotAuthenticated, err - } - - ctx.Logger.Errorf("Error occurred while attempting to update user details from LDAP: %v", err) - - return "", "", nil, nil, authentication.NotAuthenticated, err - } - - return userSession.Username, userSession.DisplayName, userSession.Groups, userSession.Emails, userSession.AuthenticationLevel, nil -} - -func handleUnauthorized(ctx *middlewares.AutheliaCtx, targetURL fmt.Stringer, cookieDomain string, isBasicAuth bool, username string, method []byte) { - var ( - statusCode int - friendlyUsername string - friendlyRequestMethod string - ) - - switch username { - case "": - friendlyUsername = "" - default: - friendlyUsername = username - } - - if isBasicAuth { - ctx.Logger.Infof("Access to %s is not authorized to user %s, sending 401 response with basic auth header", targetURL.String(), friendlyUsername) - ctx.ReplyUnauthorized() - ctx.Response.Header.Add("WWW-Authenticate", "Basic realm=\"Authentication required\"") - - return - } - - rm := string(method) - - switch rm { - case "": - friendlyRequestMethod = "unknown" - default: - friendlyRequestMethod = rm - } - - redirectionURL := ctxGetPortalURL(ctx) - - if redirectionURL != nil { - if !utils.IsURISafeRedirection(redirectionURL, cookieDomain) { - ctx.Logger.Errorf("Configured Portal URL '%s' does not appear to be able to write cookies for the '%s' domain", redirectionURL, cookieDomain) - - ctx.ReplyUnauthorized() - - return - } - - qry := redirectionURL.Query() - - qry.Set(queryArgRD, targetURL.String()) - - if rm != "" { - qry.Set("rm", rm) - } - - redirectionURL.RawQuery = qry.Encode() - } - - switch { - case ctx.IsXHR() || !ctx.AcceptsMIME("text/html") || redirectionURL == nil: - statusCode = fasthttp.StatusUnauthorized - default: - switch rm { - case fasthttp.MethodGet, fasthttp.MethodOptions, "": - statusCode = fasthttp.StatusFound - default: - statusCode = fasthttp.StatusSeeOther - } - } - - if redirectionURL != nil { - ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, responding with status code %d with location redirect to %s", targetURL.String(), friendlyRequestMethod, friendlyUsername, statusCode, redirectionURL) - ctx.SpecialRedirect(redirectionURL.String(), statusCode) - } else { - ctx.Logger.Infof("Access to %s (method %s) is not authorized to user %s, responding with status code %d", targetURL.String(), friendlyRequestMethod, friendlyUsername, statusCode) - ctx.ReplyUnauthorized() - } -} - -func updateActivityTimestamp(ctx *middlewares.AutheliaCtx, isBasicAuth bool) error { - if isBasicAuth { - return nil - } - - userSession := ctx.GetSession() - // We don't need to update the activity timestamp when user checked keep me logged in. - if userSession.KeepMeLoggedIn { - return nil - } - - // Mark current activity. - userSession.LastActivity = ctx.Clock.Now().Unix() - - return ctx.SaveSession(userSession) -} - -// generateVerifySessionHasUpToDateProfileTraceLogs is used to generate trace logs only when trace logging is enabled. -// The information calculated in this function is completely useless other than trace for now. -func generateVerifySessionHasUpToDateProfileTraceLogs(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, - details *authentication.UserDetails) { - groupsAdded, groupsRemoved := utils.StringSlicesDelta(userSession.Groups, details.Groups) - emailsAdded, emailsRemoved := utils.StringSlicesDelta(userSession.Emails, details.Emails) - nameDelta := userSession.DisplayName != details.DisplayName - - // Check Groups. - var groupsDelta []string - if len(groupsAdded) != 0 { - groupsDelta = append(groupsDelta, fmt.Sprintf("added: %s.", strings.Join(groupsAdded, ", "))) - } - - if len(groupsRemoved) != 0 { - groupsDelta = append(groupsDelta, fmt.Sprintf("removed: %s.", strings.Join(groupsRemoved, ", "))) - } - - if len(groupsDelta) != 0 { - ctx.Logger.Tracef("Updated groups detected for %s. %s", userSession.Username, strings.Join(groupsDelta, " ")) - } else { - ctx.Logger.Tracef("No updated groups detected for %s", userSession.Username) - } - - // Check Emails. - var emailsDelta []string - if len(emailsAdded) != 0 { - emailsDelta = append(emailsDelta, fmt.Sprintf("added: %s.", strings.Join(emailsAdded, ", "))) - } - - if len(emailsRemoved) != 0 { - emailsDelta = append(emailsDelta, fmt.Sprintf("removed: %s.", strings.Join(emailsRemoved, ", "))) - } - - if len(emailsDelta) != 0 { - ctx.Logger.Tracef("Updated emails detected for %s. %s", userSession.Username, strings.Join(emailsDelta, " ")) - } else { - ctx.Logger.Tracef("No updated emails detected for %s", userSession.Username) - } - - // Check Name. - if nameDelta { - ctx.Logger.Tracef("Updated display name detected for %s. Added: %s. Removed: %s.", userSession.Username, details.DisplayName, userSession.DisplayName) - } else { - ctx.Logger.Tracef("No updated display name detected for %s", userSession.Username) - } -} - -func verifySessionHasUpToDateProfile(ctx *middlewares.AutheliaCtx, targetURL *url.URL, userSession *session.UserSession, - refreshProfile bool, refreshProfileInterval time.Duration) error { - // TODO: Add a check for LDAP password changes based on a time format attribute. - // See https://www.authelia.com/o/threatmodel#potential-future-guarantees - ctx.Logger.Tracef("Checking if we need check the authentication backend for an updated profile for %s.", userSession.Username) - - if !refreshProfile || userSession.IsAnonymous() || targetURL == nil { - return nil - } - - if refreshProfileInterval != schema.RefreshIntervalAlways && userSession.RefreshTTL.After(ctx.Clock.Now()) { - return nil - } - - ctx.Logger.Debugf("Checking the authentication backend for an updated profile for user %s", userSession.Username) - details, err := ctx.Providers.UserProvider.GetDetails(userSession.Username) - // Only update the session if we could get the new details. - if err != nil { - return err - } - - emailsDiff := utils.IsStringSlicesDifferent(userSession.Emails, details.Emails) - groupsDiff := utils.IsStringSlicesDifferent(userSession.Groups, details.Groups) - nameDiff := userSession.DisplayName != details.DisplayName - - if !groupsDiff && !emailsDiff && !nameDiff { - ctx.Logger.Tracef("Updated profile not detected for %s.", userSession.Username) - // Only update TTL if the user has an interval set. - // We get to this check when there were no changes. - // Also make sure to update the session even if no difference was found. - // This is so that we don't check every subsequent request after this one. - if refreshProfileInterval != schema.RefreshIntervalAlways { - // Update RefreshTTL and save session if refresh is not set to always. - userSession.RefreshTTL = ctx.Clock.Now().Add(refreshProfileInterval) - return ctx.SaveSession(*userSession) - } - } else { - ctx.Logger.Debugf("Updated profile detected for %s.", userSession.Username) - if ctx.Configuration.Log.Level == "trace" { - generateVerifySessionHasUpToDateProfileTraceLogs(ctx, userSession, details) - } - userSession.Emails = details.Emails - userSession.Groups = details.Groups - userSession.DisplayName = details.DisplayName - - // Only update TTL if the user has a interval set. - if refreshProfileInterval != schema.RefreshIntervalAlways { - userSession.RefreshTTL = ctx.Clock.Now().Add(refreshProfileInterval) - } - // Return the result of save session if there were changes. - return ctx.SaveSession(*userSession) - } - - // Return nil if disabled or if no changes and refresh interval set to always. - return nil -} - -func getProfileRefreshSettings(cfg schema.AuthenticationBackend) (refresh bool, refreshInterval time.Duration) { - if cfg.LDAP != nil { - if cfg.RefreshInterval == schema.ProfileRefreshDisabled { - refresh = false - refreshInterval = 0 - } else { - refresh = true - - if cfg.RefreshInterval != schema.ProfileRefreshAlways { - // Skip Error Check since validator checks it. - refreshInterval, _ = utils.ParseDurationString(cfg.RefreshInterval) - } else { - refreshInterval = schema.RefreshIntervalAlways - } - } - } - - return refresh, refreshInterval -} - -func verifyAuth(ctx *middlewares.AutheliaCtx, targetURL *url.URL, refreshProfile bool, refreshProfileInterval time.Duration) (isBasicAuth bool, username, name string, groups, emails []string, authLevel authentication.Level, err error) { - authHeader := headerProxyAuthorization - if bytes.Equal(ctx.QueryArgs().Peek("auth"), []byte("basic")) { - authHeader = headerAuthorization - isBasicAuth = true - } - - authValue := ctx.Request.Header.PeekBytes(authHeader) - if authValue != nil { - isBasicAuth = true - } else if isBasicAuth { - return isBasicAuth, username, name, groups, emails, authLevel, fmt.Errorf("basic auth requested via query arg, but no value provided via %s header", authHeader) - } - - if isBasicAuth { - username, name, groups, emails, authLevel, err = verifyBasicAuth(ctx, authHeader, authValue) - - return isBasicAuth, username, name, groups, emails, authLevel, err - } - - userSession := ctx.GetSession() - - if username, name, groups, emails, authLevel, err = verifySessionCookie(ctx, targetURL, &userSession, refreshProfile, refreshProfileInterval); err != nil { - return isBasicAuth, username, name, groups, emails, authLevel, err - } - - sessionUsername := ctx.Request.Header.PeekBytes(headerSessionUsername) - if sessionUsername != nil && !strings.EqualFold(string(sessionUsername), username) { - ctx.Logger.Warnf("Possible cookie hijack or attempt to bypass security detected destroying the session and sending 401 response") - - if err = ctx.DestroySession(); err != nil { - ctx.Logger.Errorf("Unable to destroy user session after handler could not match them to their %s header: %s", headerSessionUsername, err) - } - - return isBasicAuth, username, name, groups, emails, authLevel, fmt.Errorf("could not match user %s to their %s header with a value of %s when visiting %s", username, headerSessionUsername, sessionUsername, targetURL.String()) - } - - return isBasicAuth, username, name, groups, emails, authLevel, err -} - -// VerifyGET returns the handler verifying if a request is allowed to go through. -func VerifyGET(cfg schema.AuthenticationBackend) middlewares.RequestHandler { - refreshProfile, refreshProfileInterval := getProfileRefreshSettings(cfg) - - return func(ctx *middlewares.AutheliaCtx) { - ctx.Logger.Tracef("Headers=%s", ctx.Request.Header.String()) - targetURL, err := ctx.GetOriginalURL() - - if err != nil { - ctx.Logger.Errorf("Unable to parse target URL: %s", err) - ctx.ReplyUnauthorized() - - return - } - - if !utils.IsURISecure(targetURL) { - ctx.Logger.Errorf("Scheme of target URL %s must be secure since cookies are "+ - "only transported over a secure connection for security reasons", targetURL.String()) - ctx.ReplyUnauthorized() - - return - } - - cookieDomain := ctx.GetTargetURICookieDomain(targetURL) - - if cookieDomain == "" { - l := len(ctx.Configuration.Session.Cookies) - - if l == 1 { - ctx.Logger.Errorf("Target URL '%s' was not detected as a match to the '%s' session cookie domain", - targetURL.String(), ctx.Configuration.Session.Cookies[0].Domain) - } else { - domains := make([]string, 0, len(ctx.Configuration.Session.Cookies)) - - for i, domain := range ctx.Configuration.Session.Cookies { - domains[i] = domain.Domain - } - - ctx.Logger.Errorf("Target URL '%s' was not detected as a match to any of the '%s' session cookie domains", - targetURL.String(), strings.Join(domains, "', '")) - } - - ctx.ReplyUnauthorized() - - return - } - - ctx.Logger.Debugf("Target URL '%s' was detected as a match to the '%s' session cookie domain", targetURL.String(), cookieDomain) - - method := ctx.XForwardedMethod() - isBasicAuth, username, name, groups, emails, authLevel, err := verifyAuth(ctx, targetURL, refreshProfile, refreshProfileInterval) - - if err != nil { - ctx.Logger.Errorf("Error caught when verifying user authorization: %s", err) - - if err = updateActivityTimestamp(ctx, isBasicAuth); err != nil { - ctx.Error(fmt.Errorf("unable to update last activity: %s", err), messageOperationFailed) - return - } - - handleUnauthorized(ctx, targetURL, cookieDomain, isBasicAuth, username, method) - - return - } - - authorized := isTargetURLAuthorized(ctx.Providers.Authorizer, targetURL, username, - groups, ctx.RemoteIP(), method, authLevel) - - switch authorized { - case Forbidden: - ctx.Logger.Infof("Access to %s is forbidden to user %s", targetURL.String(), username) - ctx.ReplyForbidden() - case NotAuthorized: - handleUnauthorized(ctx, targetURL, cookieDomain, isBasicAuth, username, method) - case Authorized: - setForwardedHeaders(&ctx.Response.Header, username, name, groups, emails) - } - - if err = updateActivityTimestamp(ctx, isBasicAuth); err != nil { - ctx.Error(fmt.Errorf("unable to update last activity: %s", err), messageOperationFailed) - } - } -} diff --git a/internal/handlers/handler_verify_test.go b/internal/handlers/handler_verify_test.go deleted file mode 100644 index 20cbf8f22..000000000 --- a/internal/handlers/handler_verify_test.go +++ /dev/null @@ -1,1503 +0,0 @@ -package handlers - -import ( - "fmt" - "net" - "net/url" - "regexp" - "testing" - "time" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - "github.com/valyala/fasthttp" - - "github.com/authelia/authelia/v4/internal/authentication" - "github.com/authelia/authelia/v4/internal/authorization" - "github.com/authelia/authelia/v4/internal/configuration/schema" - "github.com/authelia/authelia/v4/internal/mocks" - "github.com/authelia/authelia/v4/internal/session" - "github.com/authelia/authelia/v4/internal/utils" -) - -var verifyGetCfg = schema.AuthenticationBackend{ - RefreshInterval: schema.RefreshIntervalDefault, - LDAP: &schema.LDAPAuthenticationBackend{}, -} - -func TestShouldRaiseWhenTargetUrlIsMalformed(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("X-Forwarded-URI", "/abc") - originalURL, err := mock.Ctx.GetOriginalURL() - assert.NoError(t, err) - - expectedURL, err := url.ParseRequestURI("https://home.example.com/abc") - assert.NoError(t, err) - assert.Equal(t, expectedURL, originalURL) -} - -func TestShouldRaiseWhenNoHeaderProvidedToDetectTargetURL(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - mock.Ctx.Request.Header.Del("X-Forwarded-Host") - - defer mock.Close() - _, err := mock.Ctx.GetOriginalURL() - assert.Error(t, err) - assert.Equal(t, "Missing header X-Forwarded-Host", err.Error()) -} - -func TestShouldRaiseWhenNoXForwardedHostHeaderProvidedToDetectTargetURL(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Ctx.Request.Header.Del("X-Forwarded-Host") - mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https") - _, err := mock.Ctx.GetOriginalURL() - assert.Error(t, err) - assert.Equal(t, "Missing header X-Forwarded-Host", err.Error()) -} - -func TestShouldRaiseWhenXForwardedProtoIsNotParsable(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "!:;;:,") - mock.Ctx.Request.Header.Set("X-Forwarded-Host", "myhost.local") - - _, err := mock.Ctx.GetOriginalURL() - assert.Error(t, err) - assert.Equal(t, "Unable to parse URL !:;;:,://myhost.local/: parse \"!:;;:,://myhost.local/\": invalid URI for request", err.Error()) -} - -func TestShouldRaiseWhenXForwardedURIIsNotParsable(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", "myhost.local") - mock.Ctx.Request.Header.Set("X-Forwarded-URI", "!:;;:,") - - _, err := mock.Ctx.GetOriginalURL() - require.Error(t, err) - assert.Equal(t, "Unable to parse URL https://myhost.local!:;;:,: parse \"https://myhost.local!:;;:,\": invalid port \":,\" after host", err.Error()) -} - -// Test parseBasicAuth. -func TestShouldRaiseWhenHeaderDoesNotContainBasicPrefix(t *testing.T) { - _, _, err := parseBasicAuth(headerProxyAuthorization, "alzefzlfzemjfej==") - assert.Error(t, err) - assert.Equal(t, "Basic prefix not found in Proxy-Authorization header", err.Error()) -} - -func TestShouldRaiseWhenCredentialsAreNotInBase64(t *testing.T) { - _, _, err := parseBasicAuth(headerProxyAuthorization, "Basic alzefzlfzemjfej==") - assert.Error(t, err) - assert.Equal(t, "illegal base64 data at input byte 16", err.Error()) -} - -func TestShouldRaiseWhenCredentialsAreNotInCorrectForm(t *testing.T) { - // The decoded format should be user:password. - _, _, err := parseBasicAuth(headerProxyAuthorization, "Basic am9obiBwYXNzd29yZA==") - assert.Error(t, err) - assert.Equal(t, "format of Proxy-Authorization header must be user:password", err.Error()) -} - -func TestShouldUseProvidedHeaderName(t *testing.T) { - // The decoded format should be user:password. - _, _, err := parseBasicAuth([]byte("HeaderName"), "") - assert.Error(t, err) - assert.Equal(t, "Basic prefix not found in HeaderName header", err.Error()) -} - -func TestShouldReturnUsernameAndPassword(t *testing.T) { - // the decoded format should be user:password. - user, password, err := parseBasicAuth(headerProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - assert.NoError(t, err) - assert.Equal(t, "john", user) - assert.Equal(t, "password", password) -} - -// Test isTargetURLAuthorized. -func TestShouldCheckAuthorizationMatching(t *testing.T) { - type Rule struct { - Policy string - AuthLevel authentication.Level - ExpectedMatching authorizationMatching - } - - rules := []Rule{ - {"bypass", authentication.NotAuthenticated, Authorized}, - {"bypass", authentication.OneFactor, Authorized}, - {"bypass", authentication.TwoFactor, Authorized}, - - {"one_factor", authentication.NotAuthenticated, NotAuthorized}, - {"one_factor", authentication.OneFactor, Authorized}, - {"one_factor", authentication.TwoFactor, Authorized}, - - {"two_factor", authentication.NotAuthenticated, NotAuthorized}, - {"two_factor", authentication.OneFactor, NotAuthorized}, - {"two_factor", authentication.TwoFactor, Authorized}, - - {"deny", authentication.NotAuthenticated, Forbidden}, - {"deny", authentication.OneFactor, Forbidden}, - {"deny", authentication.TwoFactor, Forbidden}, - } - - u, _ := url.ParseRequestURI("https://test.example.com") - - for _, rule := range rules { - authorizer := authorization.NewAuthorizer(&schema.Configuration{ - AccessControl: schema.AccessControlConfiguration{ - DefaultPolicy: "deny", - Rules: []schema.ACLRule{{ - Domains: []string{"test.example.com"}, - Policy: rule.Policy, - }}, - }}) - - username := "" - if rule.AuthLevel > authentication.NotAuthenticated { - username = testUsername - } - - matching := isTargetURLAuthorized(authorizer, u, username, []string{}, net.ParseIP("127.0.0.1"), []byte("GET"), rule.AuthLevel) - assert.Equal(t, rule.ExpectedMatching, matching, "policy=%s, authLevel=%v, expected=%v, actual=%v", - rule.Policy, rule.AuthLevel, rule.ExpectedMatching, matching) - } -} - -// Test verifyBasicAuth. -func TestShouldVerifyWrongCredentials(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, nil) - - _, _, _, _, _, err := verifyBasicAuth(mock.Ctx, headerProxyAuthorization, []byte("Basic am9objpwYXNzd29yZA==")) - - assert.Error(t, err) -} - -type BasicAuthorizationSuite struct { - suite.Suite -} - -func NewBasicAuthorizationSuite() *BasicAuthorizationSuite { - return &BasicAuthorizationSuite{} -} - -func (s *BasicAuthorizationSuite) TestShouldNotBeAbleToParseBasicAuth() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpaaaaaaaaaaaaaaaa") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://test.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) -} - -func (s *BasicAuthorizationSuite) TestShouldApplyDefaultPolicy() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://test.example.com") - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil) - - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, - }, nil) - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 403, mock.Ctx.Response.StatusCode()) -} - -func (s *BasicAuthorizationSuite) TestShouldApplyPolicyOfBypassDomain() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://bypass.example.com") - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil) - - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, - }, nil) - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 200, mock.Ctx.Response.StatusCode()) -} - -func (s *BasicAuthorizationSuite) TestShouldApplyPolicyOfOneFactorDomain() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil) - - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, - }, nil) - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 200, mock.Ctx.Response.StatusCode()) -} - -func (s *BasicAuthorizationSuite) TestShouldApplyPolicyOfTwoFactorDomain() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil) - - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, - }, nil) - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) -} - -func (s *BasicAuthorizationSuite) TestShouldApplyPolicyOfDenyDomain() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://deny.example.com") - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil) - - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, - }, nil) - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 403, mock.Ctx.Response.StatusCode()) -} - -func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgOk() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.QueryArgs().Add("auth", "basic") - mock.Ctx.Request.Header.Set("Authorization", "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil) - - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, - }, nil) - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 200, mock.Ctx.Response.StatusCode()) -} - -func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgFailingNoHeader() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.QueryArgs().Add("auth", "basic") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) - assert.Equal(s.T(), "401 Unauthorized", string(mock.Ctx.Response.Body())) - assert.NotEmpty(s.T(), mock.Ctx.Response.Header.Peek("WWW-Authenticate")) - assert.Regexp(s.T(), regexp.MustCompile("^Basic realm="), string(mock.Ctx.Response.Header.Peek("WWW-Authenticate"))) -} - -func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgFailingEmptyHeader() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.QueryArgs().Add("auth", "basic") - mock.Ctx.Request.Header.Set("Authorization", "") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) - assert.Equal(s.T(), "401 Unauthorized", string(mock.Ctx.Response.Body())) - assert.NotEmpty(s.T(), mock.Ctx.Response.Header.Peek("WWW-Authenticate")) - assert.Regexp(s.T(), regexp.MustCompile("^Basic realm="), string(mock.Ctx.Response.Header.Peek("WWW-Authenticate"))) -} - -func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgFailingWrongPassword() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.QueryArgs().Add("auth", "basic") - mock.Ctx.Request.Header.Set("Authorization", "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, nil) - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) - assert.Equal(s.T(), "401 Unauthorized", string(mock.Ctx.Response.Body())) - assert.NotEmpty(s.T(), mock.Ctx.Response.Header.Peek("WWW-Authenticate")) - assert.Regexp(s.T(), regexp.MustCompile("^Basic realm="), string(mock.Ctx.Response.Header.Peek("WWW-Authenticate"))) -} - -func (s *BasicAuthorizationSuite) TestShouldVerifyAuthBasicArgFailingWrongHeader() { - mock := mocks.NewMockAutheliaCtx(s.T()) - defer mock.Close() - - mock.Ctx.QueryArgs().Add("auth", "basic") - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) - assert.Equal(s.T(), "401 Unauthorized", string(mock.Ctx.Response.Body())) - assert.NotEmpty(s.T(), mock.Ctx.Response.Header.Peek("WWW-Authenticate")) - assert.Regexp(s.T(), regexp.MustCompile("^Basic realm="), string(mock.Ctx.Response.Header.Peek("WWW-Authenticate"))) -} - -func TestShouldVerifyAuthorizationsUsingBasicAuth(t *testing.T) { - suite.Run(t, NewBasicAuthorizationSuite()) -} - -func TestShouldVerifyWrongCredentialsInBasicAuth(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("wrongpass")). - Return(false, nil) - - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objp3cm9uZ3Bhc3M=") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://test.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - expStatus, actualStatus := 401, mock.Ctx.Response.StatusCode() - assert.Equal(t, expStatus, actualStatus, "URL=%s -> StatusCode=%d != ExpectedStatusCode=%d", - "https://test.example.com", actualStatus, expStatus) -} - -func TestShouldRedirectWithGroups(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(&schema.Configuration{ - AccessControl: schema.AccessControlConfiguration{ - DefaultPolicy: "deny", - Rules: []schema.ACLRule{ - { - Domains: []string{"app.example.com"}, - Policy: "one_factor", - Resources: []regexp.Regexp{ - *regexp.MustCompile(`^/code-(?P\w+)([/?].*)?$`), - }, - }, - }, - }, - }) - - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, "https") - mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, "app.example.com") - mock.Ctx.Request.Header.Set("X-Forwarded-Uri", "/code-test/login") - - mock.Ctx.Request.SetRequestURI("/api/verify/?rd=https://auth.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) -} - -func TestShouldVerifyFailingPasswordCheckingInBasicAuth(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("wrongpass")). - Return(false, fmt.Errorf("Failed")) - - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objp3cm9uZ3Bhc3M=") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://test.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - expStatus, actualStatus := 401, mock.Ctx.Response.StatusCode() - assert.Equal(t, expStatus, actualStatus, "URL=%s -> StatusCode=%d != ExpectedStatusCode=%d", - "https://test.example.com", actualStatus, expStatus) -} - -func TestShouldVerifyFailingDetailsFetchingInBasicAuth(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil) - - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(nil, fmt.Errorf("Failed")) - - mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://test.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - expStatus, actualStatus := 401, mock.Ctx.Response.StatusCode() - assert.Equal(t, expStatus, actualStatus, "URL=%s -> StatusCode=%d != ExpectedStatusCode=%d", - "https://test.example.com", actualStatus, expStatus) -} - -func TestShouldNotCrashOnEmptyEmail(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.Emails = nil - userSession.AuthenticationLevel = authentication.OneFactor - userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://bypass.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - expStatus, actualStatus := 200, mock.Ctx.Response.StatusCode() - assert.Equal(t, expStatus, actualStatus, "URL=%s -> StatusCode=%d != ExpectedStatusCode=%d", - "https://bypass.example.com", actualStatus, expStatus) - assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek("Remote-Email")) -} - -type Pair struct { - URL string - Username string - Emails []string - AuthenticationLevel authentication.Level - ExpectedStatusCode int -} - -func (p Pair) String() string { - return fmt.Sprintf("url=%s, username=%s, auth_lvl=%d, exp_status=%d", - p.URL, p.Username, p.AuthenticationLevel, p.ExpectedStatusCode) -} - -//nolint:gocyclo // This is a test. -func TestShouldRedirectAuthorizations(t *testing.T) { - testCases := []struct { - name string - - method, originalURL, autheliaURL string - - expected int - }{ - {"ShouldReturnFoundMethodNone", "", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusFound}, - {"ShouldReturnFoundMethodGET", "GET", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusFound}, - {"ShouldReturnFoundMethodOPTIONS", "OPTIONS", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusFound}, - {"ShouldReturnSeeOtherMethodPOST", "POST", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusSeeOther}, - {"ShouldReturnSeeOtherMethodPATCH", "PATCH", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusSeeOther}, - {"ShouldReturnSeeOtherMethodPUT", "PUT", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusSeeOther}, - {"ShouldReturnSeeOtherMethodDELETE", "DELETE", "https://one-factor.example.com/", "https://auth.example.com/", fasthttp.StatusSeeOther}, - {"ShouldReturnUnauthorizedBadDomain", "GET", "https://one-factor.example.com/", "https://auth.notexample.com/", fasthttp.StatusUnauthorized}, - } - - handler := VerifyGET(verifyGetCfg) - - for _, tc := range testCases { - var ( - suffix string - xhr bool - ) - - for i := 0; i < 2; i++ { - switch i { - case 0: - suffix += "QueryParameter" - default: - suffix += "RequestHeader" - } - - for j := 0; j < 2; j++ { - switch j { - case 0: - xhr = false - case 1: - xhr = true - suffix += "XHR" - } - - t.Run(tc.name+suffix, func(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - autheliaURL, err := url.ParseRequestURI(tc.autheliaURL) - - require.NoError(t, err) - - originalURL, err := url.ParseRequestURI(tc.originalURL) - - require.NoError(t, err) - - if xhr { - mock.Ctx.Request.Header.Set(fasthttp.HeaderXRequestedWith, "XMLHttpRequest") - } - - var rm string - - if tc.method != "" { - rm = fmt.Sprintf("&rm=%s", tc.method) - mock.Ctx.Request.Header.Set("X-Forwarded-Method", tc.method) - } - - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - mock.Ctx.Request.Header.Set("X-Original-URL", originalURL.String()) - - if i == 0 { - mock.Ctx.Request.SetRequestURI(fmt.Sprintf("/?rd=%s", url.QueryEscape(autheliaURL.String()))) - } else { - mock.Ctx.Request.Header.Set("X-Authelia-URL", autheliaURL.String()) - } - - handler(mock.Ctx) - - if xhr && tc.expected != fasthttp.StatusUnauthorized { - assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) - } else { - assert.Equal(t, tc.expected, mock.Ctx.Response.StatusCode()) - } - - switch { - case xhr && tc.expected != fasthttp.StatusUnauthorized: - href := utils.StringHTMLEscape(fmt.Sprintf("%s?rd=%s%s", autheliaURL.String(), url.QueryEscape(originalURL.String()), rm)) - assert.Equal(t, fmt.Sprintf("%d %s", href, fasthttp.StatusUnauthorized, fasthttp.StatusMessage(fasthttp.StatusUnauthorized)), string(mock.Ctx.Response.Body())) - case tc.expected >= fasthttp.StatusMultipleChoices && tc.expected < fasthttp.StatusBadRequest: - href := utils.StringHTMLEscape(fmt.Sprintf("%s?rd=%s%s", autheliaURL.String(), url.QueryEscape(originalURL.String()), rm)) - assert.Equal(t, fmt.Sprintf("%d %s", href, tc.expected, fasthttp.StatusMessage(tc.expected)), string(mock.Ctx.Response.Body())) - case tc.expected < fasthttp.StatusMultipleChoices: - assert.Equal(t, utils.StringHTMLEscape(fmt.Sprintf("%d %s", tc.expected, fasthttp.StatusMessage(tc.expected))), string(mock.Ctx.Response.Body())) - default: - assert.Equal(t, utils.StringHTMLEscape(fmt.Sprintf("%d %s", tc.expected, fasthttp.StatusMessage(tc.expected))), string(mock.Ctx.Response.Body())) - } - }) - } - } - } -} - -func TestShouldVerifyAuthorizationsUsingSessionCookie(t *testing.T) { - testCases := []Pair{ - // should apply default policy. - {"https://test.example.com", "", nil, authentication.NotAuthenticated, 403}, - {"https://bypass.example.com", "", nil, authentication.NotAuthenticated, 200}, - {"https://one-factor.example.com", "", nil, authentication.NotAuthenticated, 401}, - {"https://two-factor.example.com", "", nil, authentication.NotAuthenticated, 401}, - {"https://deny.example.com", "", nil, authentication.NotAuthenticated, 403}, - - {"https://test.example.com", "john", []string{"john.doe@example.com"}, authentication.OneFactor, 403}, - {"https://bypass.example.com", "john", []string{"john.doe@example.com"}, authentication.OneFactor, 200}, - {"https://one-factor.example.com", "john", []string{"john.doe@example.com"}, authentication.OneFactor, 200}, - {"https://two-factor.example.com", "john", []string{"john.doe@example.com"}, authentication.OneFactor, 401}, - {"https://deny.example.com", "john", []string{"john.doe@example.com"}, authentication.OneFactor, 403}, - - {"https://test.example.com", "john", []string{"john.doe@example.com"}, authentication.TwoFactor, 403}, - {"https://bypass.example.com", "john", []string{"john.doe@example.com"}, authentication.TwoFactor, 200}, - {"https://one-factor.example.com", "john", []string{"john.doe@example.com"}, authentication.TwoFactor, 200}, - {"https://two-factor.example.com", "john", []string{"john.doe@example.com"}, authentication.TwoFactor, 200}, - {"https://deny.example.com", "john", []string{"john.doe@example.com"}, authentication.TwoFactor, 403}, - } - - for i, tc := range testCases { - t.Run(tc.String(), func(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - mock.Ctx.Request.Header.Set("X-Original-URL", tc.URL) - - userSession := mock.Ctx.GetSession() - userSession.Username = tc.Username - userSession.Emails = tc.Emails - userSession.AuthenticationLevel = tc.AuthenticationLevel - userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - VerifyGET(verifyGetCfg)(mock.Ctx) - expStatus, actualStatus := tc.ExpectedStatusCode, mock.Ctx.Response.StatusCode() - assert.Equal(t, expStatus, actualStatus, "URL=%s -> AuthLevel=%d, StatusCode=%d != ExpectedStatusCode=%d", - tc.URL, tc.AuthenticationLevel, actualStatus, expStatus) - - fmt.Println(i) - if tc.ExpectedStatusCode == 200 && tc.Username != "" { - assert.Equal(t, tc.ExpectedStatusCode, mock.Ctx.Response.StatusCode()) - assert.Equal(t, []byte(tc.Username), mock.Ctx.Response.Header.Peek("Remote-User")) - assert.Equal(t, []byte("john.doe@example.com"), mock.Ctx.Response.Header.Peek("Remote-Email")) - } else { - assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek("Remote-User")) - assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek("Remote-Email")) - } - }) - } -} - -func TestShouldDestroySessionWhenInactiveForTooLong(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - clock := utils.TestingClock{} - clock.Set(time.Now()) - past := clock.Now().Add(-1 * time.Hour) - - mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity - // Reload the session provider since the configuration is indirect. - mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) - assert.Equal(t, time.Second*10, mock.Ctx.Configuration.Session.Cookies[0].Inactivity) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = past.Unix() - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - VerifyGET(verifyGetCfg)(mock.Ctx) - - // The session has been destroyed. - newUserSession := mock.Ctx.GetSession() - assert.Equal(t, "", newUserSession.Username) - assert.Equal(t, authentication.NotAuthenticated, newUserSession.AuthenticationLevel) - - // Check the inactivity timestamp has been updated to current time in the new session. - assert.Equal(t, clock.Now().Unix(), newUserSession.LastActivity) -} - -func TestShouldDestroySessionWhenInactiveForTooLongUsingDurationNotation(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - clock := utils.TestingClock{} - clock.Set(time.Now()) - - mock.Ctx.Configuration.Session.Cookies[0].Inactivity = time.Second * 10 - // Reload the session provider since the configuration is indirect. - mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) - assert.Equal(t, time.Second*10, mock.Ctx.Configuration.Session.Cookies[0].Inactivity) - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = clock.Now().Add(-1 * time.Hour).Unix() - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - // The session has been destroyed. - newUserSession := mock.Ctx.GetSession() - assert.Equal(t, "", newUserSession.Username) - assert.Equal(t, authentication.NotAuthenticated, newUserSession.AuthenticationLevel) -} - -func TestShouldKeepSessionWhenUserCheckedRememberMeAndIsInactiveForTooLong(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.Emails = []string{"john.doe@example.com"} - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = 0 - userSession.KeepMeLoggedIn = true - userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - // Check the session is still active. - newUserSession := mock.Ctx.GetSession() - assert.Equal(t, "john", newUserSession.Username) - assert.Equal(t, authentication.TwoFactor, newUserSession.AuthenticationLevel) - - // Check the inactivity timestamp is set to 0 in case remember me is checked. - assert.Equal(t, int64(0), newUserSession.LastActivity) -} - -func TestShouldKeepSessionWhenInactivityTimeoutHasNotBeenExceeded(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity - - past := mock.Clock.Now().Add(-1 * time.Hour) - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.Emails = []string{"john.doe@example.com"} - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = past.Unix() - userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - // The session has been destroyed. - newUserSession := mock.Ctx.GetSession() - assert.Equal(t, "john", newUserSession.Username) - assert.Equal(t, authentication.TwoFactor, newUserSession.AuthenticationLevel) - - // Check the inactivity timestamp has been updated to current time in the new session. - assert.Equal(t, mock.Clock.Now().Unix(), newUserSession.LastActivity) -} - -// In the case of Traefik and Nginx ingress controller in Kube, the response to an inactive -// session is 302 instead of 401. -func TestShouldRedirectWhenSessionInactiveForTooLongAndRDParamProvided(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - clock := utils.TestingClock{} - clock.Set(time.Now()) - - mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity - // Reload the session provider since the configuration is indirect. - mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) - assert.Equal(t, time.Second*10, mock.Ctx.Configuration.Session.Cookies[0].Inactivity) - - past := clock.Now().Add(-1 * time.Hour) - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = past.Unix() - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.QueryArgs().Add(queryArgRD, "https://login.example.com") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - mock.Ctx.Request.Header.Set("X-Forwarded-Method", "GET") - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(t, "302 Found", - string(mock.Ctx.Response.Body())) - assert.Equal(t, 302, mock.Ctx.Response.StatusCode()) - - // Check the inactivity timestamp has been updated to current time in the new session. - newUserSession := mock.Ctx.GetSession() - assert.Equal(t, clock.Now().Unix(), newUserSession.LastActivity) -} - -func TestShouldRedirectWithCorrectStatusCodeBasedOnRequestMethod(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Ctx.QueryArgs().Add(queryArgRD, "https://login.example.com") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - mock.Ctx.Request.Header.Set("X-Forwarded-Method", "GET") - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(t, "302 Found", - string(mock.Ctx.Response.Body())) - assert.Equal(t, 302, mock.Ctx.Response.StatusCode()) - - mock.Ctx.QueryArgs().Add(queryArgRD, "https://login.example.com") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - mock.Ctx.Request.Header.Set("X-Forwarded-Method", "POST") - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(t, "303 See Other", - string(mock.Ctx.Response.Body())) - assert.Equal(t, 303, mock.Ctx.Response.StatusCode()) -} - -func TestShouldUpdateInactivityTimestampEvenWhenHittingForbiddenResources(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity - - past := mock.Clock.Now().Add(-1 * time.Hour) - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = past.Unix() - userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://deny.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - // The resource if forbidden. - assert.Equal(t, 403, mock.Ctx.Response.StatusCode()) - - // Check the inactivity timestamp has been updated to current time in the new session. - newUserSession := mock.Ctx.GetSession() - assert.Equal(t, mock.Clock.Now().Unix(), newUserSession.LastActivity) -} - -func TestShouldURLEncodeRedirectionURLParameter(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.AuthenticationLevel = authentication.NotAuthenticated - userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - mock.Ctx.Request.SetHost("mydomain.com") - mock.Ctx.Request.SetRequestURI("/?rd=https://auth.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(t, "302 Found", - string(mock.Ctx.Response.Body())) -} - -func TestShouldURLEncodeRedirectionHeader(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.AuthenticationLevel = authentication.NotAuthenticated - userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - mock.Ctx.Request.Header.Set("X-Authelia-URL", "https://auth.example.com") - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - mock.Ctx.Request.SetHost("mydomain.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(t, "302 Found", - string(mock.Ctx.Response.Body())) -} - -func TestSchemeIsWSS(t *testing.T) { - GetURL := func(u string) *url.URL { - x, err := url.ParseRequestURI(u) - require.NoError(t, err) - - return x - } - - assert.False(t, isSchemeWSS( - GetURL("ws://mytest.example.com/abc/?query=abc"))) - assert.False(t, isSchemeWSS( - GetURL("http://mytest.example.com/abc/?query=abc"))) - assert.False(t, isSchemeWSS( - GetURL("https://mytest.example.com/abc/?query=abc"))) - assert.True(t, isSchemeWSS( - GetURL("wss://mytest.example.com/abc/?query=abc"))) -} - -func TestShouldNotRefreshUserGroupsFromBackend(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - // Setup pointer to john so we can adjust it during the test. - user := &authentication.UserDetails{ - Username: "john", - Groups: []string{ - "admin", - "users", - }, - Emails: []string{ - "john@example.com", - }, - } - - cfg := verifyGetCfg - cfg.RefreshInterval = "disable" - verifyGet := VerifyGET(cfg) - - mock.UserProviderMock.EXPECT().GetDetails("john").Times(0) - - clock := utils.TestingClock{} - clock.Set(time.Now()) - - userSession := mock.Ctx.GetSession() - userSession.Username = user.Username - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = clock.Now().Unix() - userSession.Groups = user.Groups - userSession.Emails = user.Emails - userSession.KeepMeLoggedIn = true - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - verifyGet(mock.Ctx) - assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com") - verifyGet(mock.Ctx) - assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) - - // Check Refresh TTL has not been updated. - userSession = mock.Ctx.GetSession() - - // Check user groups are correct. - require.Len(t, userSession.Groups, len(user.Groups)) - assert.Equal(t, utils.RFC3339Zero, userSession.RefreshTTL.Unix()) - assert.Equal(t, "admin", userSession.Groups[0]) - assert.Equal(t, "users", userSession.Groups[1]) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com") - verifyGet(mock.Ctx) - assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) - - // Check admin group is not removed from the session. - userSession = mock.Ctx.GetSession() - assert.Equal(t, utils.RFC3339Zero, userSession.RefreshTTL.Unix()) - require.Len(t, userSession.Groups, 2) - assert.Equal(t, "admin", userSession.Groups[0]) - assert.Equal(t, "users", userSession.Groups[1]) -} - -func TestShouldNotRefreshUserGroupsFromBackendWhenDisabled(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - // Setup user john. - user := &authentication.UserDetails{ - Username: "john", - Groups: []string{ - "admin", - "users", - }, - Emails: []string{ - "john@example.com", - }, - } - - mock.UserProviderMock.EXPECT().GetDetails("john").Times(0) - - clock := utils.TestingClock{} - clock.Set(time.Now()) - - userSession := mock.Ctx.GetSession() - userSession.Username = user.Username - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = clock.Now().Unix() - userSession.RefreshTTL = clock.Now().Add(-1 * time.Minute) - userSession.Groups = user.Groups - userSession.Emails = user.Emails - userSession.KeepMeLoggedIn = true - err := mock.Ctx.SaveSession(userSession) - - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - - config := verifyGetCfg - config.RefreshInterval = schema.ProfileRefreshDisabled - - VerifyGET(config)(mock.Ctx) - assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) - - // Session time should NOT have been updated, it should still have a refresh TTL 1 minute in the past. - userSession = mock.Ctx.GetSession() - assert.Equal(t, clock.Now().Add(-1*time.Minute).Unix(), userSession.RefreshTTL.Unix()) -} - -func TestShouldDestroySessionWhenUserNotExist(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - // Setup user john. - user := &authentication.UserDetails{ - Username: "john", - Groups: []string{ - "admin", - "users", - }, - Emails: []string{ - "john@example.com", - }, - } - - mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1) - - clock := utils.TestingClock{} - clock.Set(time.Now()) - - userSession := mock.Ctx.GetSession() - userSession.Username = user.Username - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = clock.Now().Unix() - userSession.RefreshTTL = clock.Now().Add(-1 * time.Minute) - userSession.Groups = user.Groups - userSession.Emails = user.Emails - userSession.KeepMeLoggedIn = true - err := mock.Ctx.SaveSession(userSession) - - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - - VerifyGET(verifyGetCfg)(mock.Ctx) - assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) - - // Session time should NOT have been updated, it should still have a refresh TTL 1 minute in the past. - userSession = mock.Ctx.GetSession() - assert.Equal(t, clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) - - // Simulate a Deleted User. - userSession.RefreshTTL = clock.Now().Add(-1 * time.Minute) - err = mock.Ctx.SaveSession(userSession) - - require.NoError(t, err) - - mock.UserProviderMock.EXPECT().GetDetails("john").Return(nil, authentication.ErrUserNotFound).Times(1) - - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(t, 401, mock.Ctx.Response.StatusCode()) - - userSession = mock.Ctx.GetSession() - assert.Equal(t, "", userSession.Username) - assert.Equal(t, authentication.NotAuthenticated, userSession.AuthenticationLevel) -} - -func TestShouldGetRemovedUserGroupsFromBackend(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - // Setup pointer to john so we can adjust it during the test. - user := &authentication.UserDetails{ - Username: "john", - Groups: []string{ - "admin", - "users", - }, - Emails: []string{ - "john@example.com", - }, - } - - verifyGet := VerifyGET(verifyGetCfg) - - mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(2) - - clock := utils.TestingClock{} - clock.Set(time.Now()) - - userSession := mock.Ctx.GetSession() - userSession.Username = user.Username - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = clock.Now().Unix() - userSession.RefreshTTL = clock.Now().Add(-1 * time.Minute) - userSession.Groups = user.Groups - userSession.Emails = user.Emails - userSession.KeepMeLoggedIn = true - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - verifyGet(mock.Ctx) - assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) - - // Request should get refresh settings and new user details. - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com") - verifyGet(mock.Ctx) - assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) - - // Check Refresh TTL has been updated since admin.example.com has a group subject and refresh is enabled. - userSession = mock.Ctx.GetSession() - - // Check user groups are correct. - require.Len(t, userSession.Groups, len(user.Groups)) - assert.Equal(t, clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) - assert.Equal(t, "admin", userSession.Groups[0]) - assert.Equal(t, "users", userSession.Groups[1]) - - // Remove the admin group, and force the next request to refresh. - user.Groups = []string{"users"} - userSession.RefreshTTL = clock.Now().Add(-1 * time.Second) - err = mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com") - verifyGet(mock.Ctx) - assert.Equal(t, 403, mock.Ctx.Response.StatusCode()) - - // Check admin group is removed from the session. - userSession = mock.Ctx.GetSession() - assert.Equal(t, clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) - require.Len(t, userSession.Groups, 1) - assert.Equal(t, "users", userSession.Groups[0]) -} - -func TestShouldGetAddedUserGroupsFromBackend(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - - // Setup pointer to john so we can adjust it during the test. - user := &authentication.UserDetails{ - Username: "john", - Groups: []string{ - "admin", - "users", - }, - Emails: []string{ - "john@example.com", - }, - } - - mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1) - - verifyGet := VerifyGET(verifyGetCfg) - - mock.Clock.Set(time.Now()) - - userSession := mock.Ctx.GetSession() - userSession.Username = user.Username - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = mock.Clock.Now().Unix() - userSession.RefreshTTL = mock.Clock.Now().Add(-1 * time.Minute) - userSession.Groups = user.Groups - userSession.Emails = user.Emails - userSession.KeepMeLoggedIn = true - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - verifyGet(mock.Ctx) - assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://grafana.example.com") - verifyGet(mock.Ctx) - assert.Equal(t, 403, mock.Ctx.Response.StatusCode()) - - // Check Refresh TTL has been updated since grafana.example.com has a group subject and refresh is enabled. - userSession = mock.Ctx.GetSession() - - // Check user groups are correct. - require.Len(t, userSession.Groups, len(user.Groups)) - assert.Equal(t, mock.Clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) - assert.Equal(t, "admin", userSession.Groups[0]) - assert.Equal(t, "users", userSession.Groups[1]) - - // Add the grafana group, and force the next request to refresh. - user.Groups = append(user.Groups, "grafana") - userSession.RefreshTTL = mock.Clock.Now().Add(-1 * time.Second) - err = mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - // Reset otherwise we get the last 403 when we check the Response. Is there a better way to do this? - mock.Close() - - mock = mocks.NewMockAutheliaCtx(t) - defer mock.Close() - err = mock.Ctx.SaveSession(userSession) - assert.NoError(t, err) - - mock.Clock.Set(time.Now()) - - gomock.InOrder( - mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1), - ) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://grafana.example.com") - VerifyGET(verifyGetCfg)(mock.Ctx) - assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) - - // Check admin group is removed from the session. - userSession = mock.Ctx.GetSession() - assert.Equal(t, true, userSession.KeepMeLoggedIn) - assert.Equal(t, authentication.TwoFactor, userSession.AuthenticationLevel) - assert.Equal(t, mock.Clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) - require.Len(t, userSession.Groups, 3) - assert.Equal(t, "admin", userSession.Groups[0]) - assert.Equal(t, "users", userSession.Groups[1]) - assert.Equal(t, "grafana", userSession.Groups[2]) -} - -func TestShouldCheckValidSessionUsernameHeaderAndReturn200(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - expectedStatusCode := 200 - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.AuthenticationLevel = authentication.OneFactor - userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") - mock.Ctx.Request.Header.SetBytesK(headerSessionUsername, testUsername) - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(t, expectedStatusCode, mock.Ctx.Response.StatusCode()) - assert.Equal(t, "", string(mock.Ctx.Response.Body())) -} - -func TestShouldCheckInvalidSessionUsernameHeaderAndReturn401(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - mock.Clock.Set(time.Now()) - - expectedStatusCode := 401 - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.AuthenticationLevel = authentication.OneFactor - userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") - mock.Ctx.Request.Header.SetBytesK(headerSessionUsername, "root") - VerifyGET(verifyGetCfg)(mock.Ctx) - - assert.Equal(t, expectedStatusCode, mock.Ctx.Response.StatusCode()) - assert.Equal(t, "401 Unauthorized", string(mock.Ctx.Response.Body())) -} - -func TestGetProfileRefreshSettings(t *testing.T) { - cfg := verifyGetCfg - - refresh, interval := getProfileRefreshSettings(cfg) - - assert.Equal(t, true, refresh) - assert.Equal(t, 5*time.Minute, interval) - - cfg.RefreshInterval = schema.ProfileRefreshDisabled - - refresh, interval = getProfileRefreshSettings(cfg) - - assert.Equal(t, false, refresh) - assert.Equal(t, time.Duration(0), interval) - - cfg.RefreshInterval = schema.ProfileRefreshAlways - - refresh, interval = getProfileRefreshSettings(cfg) - - assert.Equal(t, true, refresh) - assert.Equal(t, time.Duration(0), interval) -} - -func TestShouldNotRedirectRequestsForBypassACLWhenInactiveForTooLong(t *testing.T) { - mock := mocks.NewMockAutheliaCtx(t) - defer mock.Close() - - clock := utils.TestingClock{} - clock.Set(time.Now()) - past := clock.Now().Add(-1 * time.Hour) - - mock.Ctx.Configuration.Session.Cookies[0].Inactivity = testInactivity - // Reload the session provider since the configuration is indirect. - mock.Ctx.Providers.SessionProvider = session.NewProvider(mock.Ctx.Configuration.Session, nil) - assert.Equal(t, time.Second*10, mock.Ctx.Configuration.Session.Cookies[0].Inactivity) - - userSession := mock.Ctx.GetSession() - userSession.Username = testUsername - userSession.AuthenticationLevel = authentication.TwoFactor - userSession.LastActivity = past.Unix() - - err := mock.Ctx.SaveSession(userSession) - require.NoError(t, err) - - // Should respond 200 OK. - mock.Ctx.QueryArgs().Add(queryArgRD, "https://login.example.com") - mock.Ctx.Request.Header.Set("X-Forwarded-Method", "GET") - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://bypass.example.com") - VerifyGET(verifyGetCfg)(mock.Ctx) - assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) - assert.Nil(t, mock.Ctx.Response.Header.Peek("Location")) - - // Should respond 302 Found. - mock.Ctx.QueryArgs().Add(queryArgRD, "https://login.example.com") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - mock.Ctx.Request.Header.Set("X-Forwarded-Method", "GET") - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - VerifyGET(verifyGetCfg)(mock.Ctx) - assert.Equal(t, fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) - assert.Equal(t, "https://login.example.com/?rd=https%3A%2F%2Ftwo-factor.example.com&rm=GET", string(mock.Ctx.Response.Header.Peek("Location"))) - - // Should respond 401 Unauthorized. - mock.Ctx.QueryArgs().Del(queryArgRD) - mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - mock.Ctx.Request.Header.Set("X-Forwarded-Method", "GET") - mock.Ctx.Request.Header.Set("Accept", "text/html; charset=utf-8") - VerifyGET(verifyGetCfg)(mock.Ctx) - assert.Equal(t, fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) - assert.Nil(t, mock.Ctx.Response.Header.Peek("Location")) -} - -func TestIsSessionInactiveTooLong(t *testing.T) { - testCases := []struct { - name string - have *session.UserSession - now time.Time - inactivity time.Duration - expected bool - }{ - { - name: "ShouldNotBeInactiveTooLong", - have: &session.UserSession{Username: "john", LastActivity: 1656994960}, - now: time.Unix(1656994970, 0), - inactivity: time.Second * 90, - expected: false, - }, - { - name: "ShouldNotBeInactiveTooLongIfAnonymous", - have: &session.UserSession{Username: "", LastActivity: 1656994960}, - now: time.Unix(1656994990, 0), - inactivity: time.Second * 20, - expected: false, - }, - { - name: "ShouldNotBeInactiveTooLongIfRemembered", - have: &session.UserSession{Username: "john", LastActivity: 1656994960, KeepMeLoggedIn: true}, - now: time.Unix(1656994990, 0), - inactivity: time.Second * 20, - expected: false, - }, - { - name: "ShouldNotBeInactiveTooLongIfDisabled", - have: &session.UserSession{Username: "john", LastActivity: 1656994960}, - now: time.Unix(1656994990, 0), - inactivity: time.Second * 0, - expected: false, - }, - { - name: "ShouldBeInactiveTooLong", - have: &session.UserSession{Username: "john", LastActivity: 1656994960}, - now: time.Unix(4656994990, 0), - inactivity: time.Second * 1, - expected: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - ctx := mocks.NewMockAutheliaCtx(t) - - defer ctx.Close() - - ctx.Ctx.Configuration.Session.Cookies[0].Inactivity = tc.inactivity - ctx.Ctx.Providers.SessionProvider = session.NewProvider(ctx.Ctx.Configuration.Session, nil) - - ctx.Clock.Set(tc.now) - ctx.Ctx.Clock = &ctx.Clock - - actual := isSessionInactiveTooLong(ctx.Ctx, tc.have, tc.have.Username == "") - - assert.Equal(t, tc.expected, actual) - }) - } -} diff --git a/internal/handlers/handler_webauthn_devices.go b/internal/handlers/handler_webauthn_devices.go index 077cbf32d..473a20697 100644 --- a/internal/handlers/handler_webauthn_devices.go +++ b/internal/handlers/handler_webauthn_devices.go @@ -11,6 +11,7 @@ import ( "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/regulation" + "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/storage" ) @@ -34,9 +35,20 @@ func getWebauthnDeviceIDFromContext(ctx *middlewares.AutheliaCtx) (int, error) { // WebauthnDevicesGET returns all devices registered for the current user. func WebauthnDevicesGET(ctx *middlewares.AutheliaCtx) { - s := ctx.GetSession() + var ( + userSession session.UserSession + err error + ) - devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, s.Username) + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + ctx.ReplyForbidden() + + return + } + + devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, userSession.Username) if err != nil && err != storage.ErrNoWebauthnDevice { ctx.Error(err, messageOperationFailed) @@ -54,15 +66,23 @@ func WebauthnDevicePUT(ctx *middlewares.AutheliaCtx) { var ( bodyJSON bodyEditWebauthnDeviceRequest - id int - device *model.WebauthnDevice - err error + id int + device *model.WebauthnDevice + userSession session.UserSession + + err error ) - s := ctx.GetSession() + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") + + ctx.ReplyForbidden() + + return + } if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil { - ctx.Logger.Errorf("Unable to parse %s update request data for user '%s': %+v", regulation.AuthTypeWebauthn, s.Username, err) + ctx.Logger.Errorf("Unable to parse %s update request data for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) ctx.SetStatusCode(fasthttp.StatusBadRequest) ctx.Error(err, messageOperationFailed) @@ -79,12 +99,12 @@ func WebauthnDevicePUT(ctx *middlewares.AutheliaCtx) { return } - if device.Username != s.Username { - ctx.Error(fmt.Errorf("user '%s' tried to delete device with id '%d' which belongs to '%s", s.Username, device.ID, device.Username), messageOperationFailed) + if device.Username != userSession.Username { + ctx.Error(fmt.Errorf("user '%s' tried to delete device with id '%d' which belongs to '%s", userSession.Username, device.ID, device.Username), messageOperationFailed) return } - if err = ctx.Providers.StorageProvider.UpdateWebauthnDeviceDescription(ctx, s.Username, id, bodyJSON.Description); err != nil { + if err = ctx.Providers.StorageProvider.UpdateWebauthnDeviceDescription(ctx, userSession.Username, id, bodyJSON.Description); err != nil { ctx.Error(err, messageOperationFailed) return } @@ -93,9 +113,10 @@ func WebauthnDevicePUT(ctx *middlewares.AutheliaCtx) { // WebauthnDeviceDELETE deletes a specific device for the current user. func WebauthnDeviceDELETE(ctx *middlewares.AutheliaCtx) { var ( - id int - device *model.WebauthnDevice - err error + id int + device *model.WebauthnDevice + userSession session.UserSession + err error ) if id, err = getWebauthnDeviceIDFromContext(ctx); err != nil { @@ -107,10 +128,16 @@ func WebauthnDeviceDELETE(ctx *middlewares.AutheliaCtx) { return } - s := ctx.GetSession() + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Error("Error occurred retrieving user session") - if device.Username != s.Username { - ctx.Error(fmt.Errorf("user '%s' tried to delete device with id '%d' which belongs to '%s", s.Username, device.ID, device.Username), messageOperationFailed) + ctx.ReplyForbidden() + + return + } + + if device.Username != userSession.Username { + ctx.Error(fmt.Errorf("user '%s' tried to delete device with id '%d' which belongs to '%s", userSession.Username, device.ID, device.Username), messageOperationFailed) return } diff --git a/internal/handlers/response.go b/internal/handlers/response.go index 89e964630..b3de77c3c 100644 --- a/internal/handlers/response.go +++ b/internal/handlers/response.go @@ -13,6 +13,7 @@ import ( "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/oidc" + "github.com/authelia/authelia/v4/internal/session" ) // Handle1FAResponse handle the redirection upon 1FA authentication. @@ -155,7 +156,13 @@ func handleOIDCWorkflowResponseWithTargetURL(ctx *middlewares.AutheliaCtx, targe return } - userSession := ctx.GetSession() + var userSession session.UserSession + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Error(fmt.Errorf("unable to redirect to '%s': failed to lookup session: %w", targetURL, err), messageAuthenticationFailed) + + return + } if userSession.IsAnonymous() { ctx.Error(fmt.Errorf("unable to redirect to '%s': user is anonymous", targetURL), messageAuthenticationFailed) @@ -200,7 +207,13 @@ func handleOIDCWorkflowResponseWithID(ctx *middlewares.AutheliaCtx, id string) { return } - userSession := ctx.GetSession() + var userSession session.UserSession + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Error(fmt.Errorf("unable to redirect for authorization/consent for client with id '%s' with consent challenge id '%s': failed to lookup session: %w", client.ID, consent.ChallengeID, err), messageAuthenticationFailed) + + return + } if userSession.IsAnonymous() { ctx.Error(fmt.Errorf("unable to redirect for authorization/consent for client with id '%s' with consent challenge id '%s': user is anonymous", client.ID, consent.ChallengeID), messageAuthenticationFailed) @@ -251,7 +264,7 @@ func markAuthenticationAttempt(ctx *middlewares.AutheliaCtx, successful bool, ba refererURL, err := url.ParseRequestURI(string(referer)) if err == nil { requestURI = refererURL.Query().Get(queryArgRD) - requestMethod = refererURL.Query().Get("rm") + requestMethod = refererURL.Query().Get(queryArgRM) } } diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 65856dfb3..51d33db0b 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -17,8 +17,6 @@ import ( // MethodList is the list of available methods. type MethodList = []string -type authorizationMatching int - // configurationBody the content returned by the configuration endpoint. type configurationBody struct { AvailableMethods MethodList `json:"available_methods"` diff --git a/internal/handlers/util.go b/internal/handlers/util.go index 431bbc0ce..f29111dce 100644 --- a/internal/handlers/util.go +++ b/internal/handlers/util.go @@ -1,33 +1,13 @@ package handlers import ( - "bytes" "fmt" - "net/url" "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/templates" ) -var bytesEmpty = []byte("") - -func ctxGetPortalURL(ctx *middlewares.AutheliaCtx) (portalURL *url.URL) { - var rawURL []byte - - if rawURL = ctx.QueryArgRedirect(); rawURL != nil && !bytes.Equal(rawURL, bytesEmpty) { - portalURL, _ = url.ParseRequestURI(string(rawURL)) - - return portalURL - } else if rawURL = ctx.XAutheliaURL(); rawURL != nil && !bytes.Equal(rawURL, bytesEmpty) { - portalURL, _ = url.ParseRequestURI(string(rawURL)) - - return portalURL - } - - return nil -} - func ctxLogEvent(ctx *middlewares.AutheliaCtx, username, description string, eventDetails map[string]any) { var ( details *authentication.UserDetails diff --git a/internal/handlers/webauthn.go b/internal/handlers/webauthn.go index 81624e92e..d9703e2df 100644 --- a/internal/handlers/webauthn.go +++ b/internal/handlers/webauthn.go @@ -35,7 +35,7 @@ func newWebauthn(ctx *middlewares.AutheliaCtx) (w *webauthn.WebAuthn, err error) u *url.URL ) - if u, err = ctx.GetOriginalURL(); err != nil { + if u, err = ctx.GetXOriginalURLOrXForwardedURL(); err != nil { return nil, err } diff --git a/internal/handlers/webauthn_test.go b/internal/handlers/webauthn_test.go index 7695adc3b..44e5b237d 100644 --- a/internal/handlers/webauthn_test.go +++ b/internal/handlers/webauthn_test.go @@ -151,7 +151,7 @@ func TestWebauthnNewWebauthnShouldReturnErrWhenHeadersNotAvailable(t *testing.T) w, err := newWebauthn(ctx.Ctx) assert.Nil(t, w) - assert.EqualError(t, err, "Missing header X-Forwarded-Host") + assert.EqualError(t, err, "missing required X-Forwarded-Host header") } func TestWebauthnNewWebauthnShouldReturnErrWhenWebauthnNotConfigured(t *testing.T) { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 82b490edf..4a4a03da5 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -15,6 +15,6 @@ type Provider interface { // Recorder of metrics. type Recorder interface { RecordRequest(statusCode, requestMethod string, elapsed time.Duration) - RecordVerifyRequest(statusCode string) + RecordAuthz(statusCode string) RecordAuthenticationDuration(success bool, elapsed time.Duration) } diff --git a/internal/metrics/prometheus.go b/internal/metrics/prometheus.go index 61ae96951..8b5a375b5 100644 --- a/internal/metrics/prometheus.go +++ b/internal/metrics/prometheus.go @@ -19,12 +19,12 @@ func NewPrometheus() (provider *Prometheus) { // Prometheus is a middleware for recording prometheus metrics. type Prometheus struct { - authDuration *prometheus.HistogramVec - reqDuration *prometheus.HistogramVec - reqCounter *prometheus.CounterVec - reqVerifyCounter *prometheus.CounterVec - auth1FACounter *prometheus.CounterVec - auth2FACounter *prometheus.CounterVec + authnDuration *prometheus.HistogramVec + reqDuration *prometheus.HistogramVec + reqCounter *prometheus.CounterVec + authzCounter *prometheus.CounterVec + authnCounter *prometheus.CounterVec + authn2FACounter *prometheus.CounterVec } // RecordRequest takes the statusCode string, requestMethod string, and the elapsed time.Duration to record the request and request duration metrics. @@ -33,31 +33,31 @@ func (r *Prometheus) RecordRequest(statusCode, requestMethod string, elapsed tim r.reqDuration.WithLabelValues(statusCode).Observe(elapsed.Seconds()) } -// RecordVerifyRequest takes the statusCode string to record the verify endpoint request metrics. -func (r *Prometheus) RecordVerifyRequest(statusCode string) { - r.reqVerifyCounter.WithLabelValues(statusCode).Inc() +// RecordAuthz takes the statusCode string to record the verify endpoint request metrics. +func (r *Prometheus) RecordAuthz(statusCode string) { + r.authzCounter.WithLabelValues(statusCode).Inc() } -// RecordAuthentication takes the success and regulated booleans and a method string to record the authentication metrics. -func (r *Prometheus) RecordAuthentication(success, banned bool, authType string) { +// RecordAuthn takes the success and regulated booleans and a method string to record the authentication metrics. +func (r *Prometheus) RecordAuthn(success, banned bool, authType string) { switch authType { case "1fa", "": - r.auth1FACounter.WithLabelValues(strconv.FormatBool(success), strconv.FormatBool(banned)).Inc() + r.authnCounter.WithLabelValues(strconv.FormatBool(success), strconv.FormatBool(banned)).Inc() default: - r.auth2FACounter.WithLabelValues(strconv.FormatBool(success), strconv.FormatBool(banned), authType).Inc() + r.authn2FACounter.WithLabelValues(strconv.FormatBool(success), strconv.FormatBool(banned), authType).Inc() } } // RecordAuthenticationDuration takes the statusCode string, requestMethod string, and the elapsed time.Duration to record the request and request duration metrics. func (r *Prometheus) RecordAuthenticationDuration(success bool, elapsed time.Duration) { - r.authDuration.WithLabelValues(strconv.FormatBool(success)).Observe(elapsed.Seconds()) + r.authnDuration.WithLabelValues(strconv.FormatBool(success)).Observe(elapsed.Seconds()) } func (r *Prometheus) register() { - r.authDuration = promauto.NewHistogramVec( + r.authnDuration = promauto.NewHistogramVec( prometheus.HistogramOpts{ Subsystem: "authelia", - Name: "authentication_duration", + Name: "authn_duration", Help: "The time an authentication attempt takes in seconds.", Buckets: []float64{.0005, .00075, .001, .005, .01, .025, .05, .075, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 0.9, 1, 5, 10, 15, 30, 60}, }, @@ -83,28 +83,28 @@ func (r *Prometheus) register() { []string{"code", "method"}, ) - r.reqVerifyCounter = promauto.NewCounterVec( + r.authzCounter = promauto.NewCounterVec( prometheus.CounterOpts{ Subsystem: "authelia", - Name: "verify_request", - Help: "The number of verify requests processed.", + Name: "authz", + Help: "The number of authz requests processed.", }, []string{"code"}, ) - r.auth1FACounter = promauto.NewCounterVec( + r.authnCounter = promauto.NewCounterVec( prometheus.CounterOpts{ Subsystem: "authelia", - Name: "authentication_first_factor", + Name: "authn", Help: "The number of 1FA authentications processed.", }, []string{"success", "banned"}, ) - r.auth2FACounter = promauto.NewCounterVec( + r.authn2FACounter = promauto.NewCounterVec( prometheus.CounterOpts{ Subsystem: "authelia", - Name: "authentication_second_factor", + Name: "authn_second_factor", Help: "The number of 2FA authentications processed.", }, []string{"success", "banned", "type"}, diff --git a/internal/middlewares/authelia_context.go b/internal/middlewares/authelia_context.go index 93e67aec5..7545c1cbd 100644 --- a/internal/middlewares/authelia_context.go +++ b/internal/middlewares/authelia_context.go @@ -2,9 +2,11 @@ package middlewares import ( "encoding/json" + "errors" "fmt" "net" "net/url" + "path" "strings" "github.com/asaskevich/govalidator" @@ -139,12 +141,17 @@ func (ctx *AutheliaCtx) ReplyBadRequest() { ctx.ReplyStatusCode(fasthttp.StatusBadRequest) } -// XForwardedProto return the content of the X-Forwarded-Proto header. +// XForwardedMethod returns the content of the X-Forwarded-Method header. +func (ctx *AutheliaCtx) XForwardedMethod() (method []byte) { + return ctx.Request.Header.PeekBytes(headerXForwardedMethod) +} + +// XForwardedProto returns the content of the X-Forwarded-Proto header. func (ctx *AutheliaCtx) XForwardedProto() (proto []byte) { - proto = ctx.RequestCtx.Request.Header.PeekBytes(headerXForwardedProto) + proto = ctx.Request.Header.PeekBytes(headerXForwardedProto) if proto == nil { - if ctx.RequestCtx.IsTLS() { + if ctx.IsTLS() { return protoHTTPS } @@ -154,14 +161,14 @@ func (ctx *AutheliaCtx) XForwardedProto() (proto []byte) { return proto } -// XForwardedMethod return the content of the X-Forwarded-Method header. -func (ctx *AutheliaCtx) XForwardedMethod() []byte { - return ctx.RequestCtx.Request.Header.PeekBytes(headerXForwardedMethod) +// XForwardedHost returns the content of the X-Forwarded-Host header. +func (ctx *AutheliaCtx) XForwardedHost() (host []byte) { + return ctx.Request.Header.PeekBytes(headerXForwardedHost) } -// XForwardedHost return the content of the X-Forwarded-Host header. -func (ctx *AutheliaCtx) XForwardedHost() (host []byte) { - host = ctx.RequestCtx.Request.Header.PeekBytes(headerXForwardedHost) +// GetXForwardedHost returns the content of the X-Forwarded-Host header falling back to the Host header. +func (ctx *AutheliaCtx) GetXForwardedHost() (host []byte) { + host = ctx.XForwardedHost() if host == nil { return ctx.RequestCtx.Host() @@ -170,41 +177,60 @@ func (ctx *AutheliaCtx) XForwardedHost() (host []byte) { return host } -// XForwardedURI return the content of the X-Forwarded-URI header. -func (ctx *AutheliaCtx) XForwardedURI() (uri []byte) { - uri = ctx.RequestCtx.Request.Header.PeekBytes(headerXForwardedURI) +// XForwardedURI returns the content of the X-Forwarded-Uri header. +func (ctx *AutheliaCtx) XForwardedURI() (host []byte) { + return ctx.Request.Header.PeekBytes(headerXForwardedURI) +} + +// GetXForwardedURI returns the content of the X-Forwarded-URI header, falling back to the start-line request path. +func (ctx *AutheliaCtx) GetXForwardedURI() (uri []byte) { + uri = ctx.XForwardedURI() if len(uri) == 0 { - return ctx.RequestCtx.RequestURI() + return ctx.RequestURI() } return uri } +// XOriginalMethod returns the content of the X-Original-Method header. +func (ctx *AutheliaCtx) XOriginalMethod() []byte { + return ctx.Request.Header.PeekBytes(headerXOriginalMethod) +} + // XOriginalURL returns the content of the X-Original-URL header. func (ctx *AutheliaCtx) XOriginalURL() []byte { - return ctx.RequestCtx.Request.Header.PeekBytes(headerXOriginalURL) + return ctx.Request.Header.PeekBytes(headerXOriginalURL) } -// XOriginalMethod return the content of the X-Original-Method header. -func (ctx *AutheliaCtx) XOriginalMethod() []byte { - return ctx.RequestCtx.Request.Header.PeekBytes(headerXOriginalMethod) -} - -// XAutheliaURL return the content of the X-Authelia-URL header which is used to communicate the location of the +// XAutheliaURL returns the content of the X-Authelia-URL header which is used to communicate the location of the // portal when using proxies like Envoy. func (ctx *AutheliaCtx) XAutheliaURL() []byte { - return ctx.RequestCtx.Request.Header.PeekBytes(headerXAutheliaURL) + return ctx.Request.Header.PeekBytes(headerXAutheliaURL) } -// QueryArgRedirect return the content of the rd query argument. +// QueryArgRedirect returns the content of the 'rd' query argument. func (ctx *AutheliaCtx) QueryArgRedirect() []byte { - return ctx.RequestCtx.QueryArgs().PeekBytes(qryArgRedirect) + return ctx.QueryArgs().PeekBytes(qryArgRedirect) +} + +// QueryArgAutheliaURL returns the content of the 'authelia_url' query argument. +func (ctx *AutheliaCtx) QueryArgAutheliaURL() []byte { + return ctx.QueryArgs().PeekBytes(qryArgAutheliaURL) +} + +// AuthzPath returns the 'authz_path' value. +func (ctx *AutheliaCtx) AuthzPath() (uri []byte) { + if uv := ctx.UserValueBytes(keyUserValueAuthzPath); uv != nil { + return []byte(uv.(string)) + } + + return nil } // BasePath returns the base_url as per the path visited by the client. func (ctx *AutheliaCtx) BasePath() string { - if baseURL := ctx.UserValueBytes(UserValueKeyBaseURL); baseURL != nil { + if baseURL := ctx.UserValueBytes(keyUserValueBaseURL); baseURL != nil { return baseURL.(string) } @@ -213,7 +239,7 @@ func (ctx *AutheliaCtx) BasePath() string { // BasePathSlash is the same as BasePath but returns a final slash as well. func (ctx *AutheliaCtx) BasePathSlash() string { - if baseURL := ctx.UserValueBytes(UserValueKeyBaseURL); baseURL != nil { + if baseURL := ctx.UserValueBytes(keyUserValueBaseURL); baseURL != nil { return baseURL.(string) + strSlash } @@ -224,7 +250,7 @@ func (ctx *AutheliaCtx) BasePathSlash() string { func (ctx *AutheliaCtx) RootURL() (issuerURL *url.URL) { return &url.URL{ Scheme: string(ctx.XForwardedProto()), - Host: string(ctx.XForwardedHost()), + Host: string(ctx.GetXForwardedHost()), Path: ctx.BasePath(), } } @@ -233,13 +259,17 @@ func (ctx *AutheliaCtx) RootURL() (issuerURL *url.URL) { func (ctx *AutheliaCtx) RootURLSlash() (issuerURL *url.URL) { return &url.URL{ Scheme: string(ctx.XForwardedProto()), - Host: string(ctx.XForwardedHost()), + Host: string(ctx.GetXForwardedHost()), Path: ctx.BasePathSlash(), } } // GetTargetURICookieDomain returns the session provider for the targetURI domain. func (ctx *AutheliaCtx) GetTargetURICookieDomain(targetURI *url.URL) string { + if targetURI == nil { + return "" + } + hostname := targetURI.Hostname() for _, domain := range ctx.Configuration.Session.Cookies { @@ -264,46 +294,82 @@ func (ctx *AutheliaCtx) IsSafeRedirectionTargetURI(targetURI *url.URL) bool { func (ctx *AutheliaCtx) GetCookieDomain() (domain string, err error) { var targetURI *url.URL - if targetURI, err = ctx.GetOriginalURL(); err != nil { + if targetURI, err = ctx.GetXOriginalURLOrXForwardedURL(); err != nil { return "", fmt.Errorf("unable to retrieve cookie domain: %s", err) } return ctx.GetTargetURICookieDomain(targetURI), nil } +// GetSessionProviderByTargetURL returns the session provider for the Request's domain. +func (ctx *AutheliaCtx) GetSessionProviderByTargetURL(targetURL *url.URL) (provider *session.Session, err error) { + domain := ctx.GetTargetURICookieDomain(targetURL) + + if domain == "" { + return nil, fmt.Errorf("unable to retrieve domain session: %w", err) + } + + return ctx.Providers.SessionProvider.Get(domain) +} + // GetSessionProvider returns the session provider for the Request's domain. func (ctx *AutheliaCtx) GetSessionProvider() (provider *session.Session, err error) { - var cookieDomain string + if ctx.session == nil { + var domain string - if cookieDomain, err = ctx.GetCookieDomain(); err != nil { - return nil, err + if domain, err = ctx.GetCookieDomain(); err != nil { + return nil, err + } + + if ctx.session, err = ctx.GetCookieDomainSessionProvider(domain); err != nil { + return nil, err + } } - if cookieDomain == "" { - return nil, fmt.Errorf("unable to retrieve domain session: %s", err) - } - - return ctx.Providers.SessionProvider.Get(cookieDomain) + return ctx.session, nil } -// GetSession return the user session. Any update will be saved in cache. -func (ctx *AutheliaCtx) GetSession() session.UserSession { - provider, err := ctx.GetSessionProvider() - if err != nil { - ctx.Logger.Error("Unable to retrieve domain session") - return session.NewDefaultUserSession() +// GetCookieDomainSessionProvider returns the session provider for the provided domain. +func (ctx *AutheliaCtx) GetCookieDomainSessionProvider(domain string) (provider *session.Session, err error) { + if domain == "" { + return nil, fmt.Errorf("unable to retrieve domain session: %w", err) } - userSession, err := provider.GetSession(ctx.RequestCtx) - if err != nil { + return ctx.Providers.SessionProvider.Get(domain) +} + +// GetSession returns the user session provided the cookie provider could be discovered. It is recommended to get the +// provider itself if you also need to update or destroy sessions. +func (ctx *AutheliaCtx) GetSession() (userSession session.UserSession, err error) { + var provider *session.Session + + if provider, err = ctx.GetSessionProvider(); err != nil { + return userSession, err + } + + if userSession, err = provider.GetSession(ctx.RequestCtx); err != nil { ctx.Logger.Error("Unable to retrieve user session") - return session.NewDefaultUserSession() + return provider.NewDefaultUserSession(), nil } - return userSession + if userSession.CookieDomain != provider.Config.Domain { + ctx.Logger.Warnf("Destroying session cookie as the cookie domain '%s' does not match the requests detected cookie domain '%s' which may be a sign a user tried to move this cookie from one domain to another", userSession.CookieDomain, provider.Config.Domain) + + if err = provider.DestroySession(ctx.RequestCtx); err != nil { + ctx.Logger.WithError(err).Error("Error occurred trying to destroy the session cookie") + } + + userSession = provider.NewDefaultUserSession() + + if err = provider.SaveSession(ctx.RequestCtx, userSession); err != nil { + ctx.Logger.WithError(err).Error("Error occurred trying to save the new session cookie") + } + } + + return userSession, nil } -// SaveSession save the content of the session. +// SaveSession saves the content of the session. func (ctx *AutheliaCtx) SaveSession(userSession session.UserSession) error { provider, err := ctx.GetSessionProvider() if err != nil { @@ -313,7 +379,7 @@ func (ctx *AutheliaCtx) SaveSession(userSession session.UserSession) error { return provider.SaveSession(ctx.RequestCtx, userSession) } -// RegenerateSession regenerates user session. +// RegenerateSession regenerates a user session. func (ctx *AutheliaCtx) RegenerateSession() error { provider, err := ctx.GetSessionProvider() if err != nil { @@ -323,7 +389,7 @@ func (ctx *AutheliaCtx) RegenerateSession() error { return provider.RegenerateSession(ctx.RequestCtx) } -// DestroySession destroy user session. +// DestroySession destroys a user session. func (ctx *AutheliaCtx) DestroySession() error { provider, err := ctx.GetSessionProvider() if err != nil { @@ -360,6 +426,36 @@ func (ctx *AutheliaCtx) ParseBody(value any) error { return nil } +// SetContentTypeApplicationJSON sets the Content-Type header to 'application/json; charset=utf-8'. +func (ctx *AutheliaCtx) SetContentTypeApplicationJSON() { + ctx.SetContentTypeBytes(contentTypeApplicationJSON) +} + +// SetContentTypeTextPlain efficiently sets the Content-Type header to 'text/plain; charset=utf-8'. +func (ctx *AutheliaCtx) SetContentTypeTextPlain() { + ctx.SetContentTypeBytes(contentTypeTextPlain) +} + +// SetContentTypeTextHTML efficiently sets the Content-Type header to 'text/html; charset=utf-8'. +func (ctx *AutheliaCtx) SetContentTypeTextHTML() { + ctx.SetContentTypeBytes(contentTypeTextHTML) +} + +// SetContentTypeApplicationYAML efficiently sets the Content-Type header to 'application/yaml; charset=utf-8'. +func (ctx *AutheliaCtx) SetContentTypeApplicationYAML() { + ctx.SetContentTypeBytes(contentTypeApplicationYAML) +} + +// SetContentSecurityPolicy sets the Content-Security-Policy header. +func (ctx *AutheliaCtx) SetContentSecurityPolicy(value string) { + ctx.Response.Header.SetBytesK(headerContentSecurityPolicy, value) +} + +// SetContentSecurityPolicyBytes sets the Content-Security-Policy header. +func (ctx *AutheliaCtx) SetContentSecurityPolicyBytes(value []byte) { + ctx.Response.Header.SetBytesKV(headerContentSecurityPolicy, value) +} + // SetJSONBody Set json body. func (ctx *AutheliaCtx) SetJSONBody(value any) error { return ctx.ReplyJSON(OKResponse{Status: "OK", Data: value}, 0) @@ -379,52 +475,88 @@ func (ctx *AutheliaCtx) RemoteIP() net.IP { return ctx.RequestCtx.RemoteIP() } -// GetOriginalURL extract the URL from the request headers (X-Original-URL or X-Forwarded-* headers). -func (ctx *AutheliaCtx) GetOriginalURL() (*url.URL, error) { - originalURL := ctx.XOriginalURL() - if originalURL != nil { - parsedURL, err := url.ParseRequestURI(string(originalURL)) - if err != nil { - return nil, fmt.Errorf("Unable to parse URL extracted from X-Original-URL header: %v", err) - } - - ctx.Logger.Trace("Using X-Original-URL header content as targeted site URL") - - return parsedURL, nil - } - - forwardedProto, forwardedHost, forwardedURI := ctx.XForwardedProto(), ctx.XForwardedHost(), ctx.XForwardedURI() +// GetXForwardedURL returns the parsed X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-URI request header as a +// *url.URL. +func (ctx *AutheliaCtx) GetXForwardedURL() (requestURI *url.URL, err error) { + forwardedProto, forwardedHost, forwardedURI := ctx.XForwardedProto(), ctx.GetXForwardedHost(), ctx.GetXForwardedURI() if forwardedProto == nil { - return nil, errMissingXForwardedProto + return nil, ErrMissingXForwardedProto } if forwardedHost == nil { - return nil, errMissingXForwardedHost + return nil, ErrMissingXForwardedHost } - var requestURI string + value := utils.BytesJoin(forwardedProto, protoHostSeparator, forwardedHost, forwardedURI) - forwardedProto = append(forwardedProto, protoHostSeparator...) - requestURI = string(append(forwardedProto, - append(forwardedHost, forwardedURI...)...)) - - parsedURL, err := url.ParseRequestURI(requestURI) - if err != nil { - return nil, fmt.Errorf("Unable to parse URL %s: %v", requestURI, err) + if requestURI, err = url.ParseRequestURI(string(value)); err != nil { + return nil, fmt.Errorf("failed to parse X-Forwarded Headers: %w", err) } - ctx.Logger.Tracef("Using X-Fowarded-Proto, X-Forwarded-Host and X-Forwarded-URI headers " + - "to construct targeted site URL") + return requestURI, nil +} - return parsedURL, nil +// GetXOriginalURL returns the parsed X-OriginalURL request header as a *url.URL. +func (ctx *AutheliaCtx) GetXOriginalURL() (requestURI *url.URL, err error) { + value := ctx.XOriginalURL() + + if value == nil { + return nil, ErrMissingXOriginalURL + } + + if requestURI, err = url.ParseRequestURI(string(value)); err != nil { + return nil, fmt.Errorf("failed to parse X-Original-URL header: %w", err) + } + + return requestURI, nil +} + +// GetXOriginalURLOrXForwardedURL returns the parsed X-Original-URL request header if it's available or the parsed +// X-Forwarded request headers if not. +func (ctx *AutheliaCtx) GetXOriginalURLOrXForwardedURL() (requestURI *url.URL, err error) { + requestURI, err = ctx.GetXOriginalURL() + + switch { + case err == nil: + return requestURI, nil + case errors.Is(err, ErrMissingXOriginalURL): + return ctx.GetXForwardedURL() + default: + return requestURI, err + } +} + +// IssuerURL returns the expected Issuer. +func (ctx *AutheliaCtx) IssuerURL() (issuerURL *url.URL, err error) { + issuerURL = &url.URL{ + Scheme: strProtoHTTPS, + } + + if scheme := ctx.XForwardedProto(); scheme != nil { + issuerURL.Scheme = string(scheme) + } + + if host := ctx.GetXForwardedHost(); len(host) != 0 { + issuerURL.Host = string(host) + } else { + return nil, ErrMissingXForwardedHost + } + + if base := ctx.BasePath(); base != "" { + issuerURL.Path = path.Join(issuerURL.Path, base) + } + + return issuerURL, nil } // IsXHR returns true if the request is a XMLHttpRequest. func (ctx *AutheliaCtx) IsXHR() (xhr bool) { - requestedWith := ctx.Request.Header.PeekBytes(headerXRequestedWith) + if requestedWith := ctx.Request.Header.PeekBytes(headerXRequestedWith); requestedWith != nil && strings.EqualFold(string(requestedWith), headerValueXRequestedWithXHR) { + return true + } - return requestedWith != nil && strings.EqualFold(string(requestedWith), headerValueXRequestedWithXHR) + return false } // AcceptsMIME takes a mime type and returns true if the request accepts that type or the wildcard type. @@ -463,31 +595,11 @@ func (ctx *AutheliaCtx) SpecialRedirect(uri string, statusCode int) { fasthttp.ReleaseURI(u) } -// RecordAuthentication records authentication metrics. -func (ctx *AutheliaCtx) RecordAuthentication(success, regulated bool, method string) { +// RecordAuthn records authentication metrics. +func (ctx *AutheliaCtx) RecordAuthn(success, regulated bool, method string) { if ctx.Providers.Metrics == nil { return } - ctx.Providers.Metrics.RecordAuthentication(success, regulated, method) -} - -// SetContentTypeTextPlain efficiently sets the Content-Type header to 'text/plain; charset=utf-8'. -func (ctx *AutheliaCtx) SetContentTypeTextPlain() { - ctx.SetContentTypeBytes(contentTypeTextPlain) -} - -// SetContentTypeTextHTML efficiently sets the Content-Type header to 'text/html; charset=utf-8'. -func (ctx *AutheliaCtx) SetContentTypeTextHTML() { - ctx.SetContentTypeBytes(contentTypeTextHTML) -} - -// SetContentTypeApplicationJSON efficiently sets the Content-Type header to 'application/json; charset=utf-8'. -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) + ctx.Providers.Metrics.RecordAuthn(success, regulated, method) } diff --git a/internal/middlewares/authelia_context_test.go b/internal/middlewares/authelia_context_test.go index abf63b4dd..ef4a761f1 100644 --- a/internal/middlewares/authelia_context_test.go +++ b/internal/middlewares/authelia_context_test.go @@ -150,7 +150,7 @@ func TestShouldGetOriginalURLFromOriginalURLHeader(t *testing.T) { defer mock.Close() mock.Ctx.Request.Header.Set("X-Original-URL", "https://home.example.com") - originalURL, err := mock.Ctx.GetOriginalURL() + originalURL, err := mock.Ctx.GetXOriginalURLOrXForwardedURL() assert.NoError(t, err) expectedURL, err := url.ParseRequestURI("https://home.example.com") @@ -163,7 +163,7 @@ func TestShouldGetOriginalURLFromForwardedHeadersWithoutURI(t *testing.T) { defer mock.Close() mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedProto, "https") mock.Ctx.Request.Header.Set(fasthttp.HeaderXForwardedHost, "home.example.com") - originalURL, err := mock.Ctx.GetOriginalURL() + originalURL, err := mock.Ctx.GetXOriginalURLOrXForwardedURL() assert.NoError(t, err) expectedURL, err := url.ParseRequestURI("https://home.example.com/") @@ -175,9 +175,9 @@ func TestShouldGetOriginalURLFromForwardedHeadersWithURI(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() mock.Ctx.Request.Header.Set("X-Original-URL", "htt-ps//home?-.example.com") - _, err := mock.Ctx.GetOriginalURL() + _, err := mock.Ctx.GetXOriginalURLOrXForwardedURL() assert.Error(t, err) - assert.Equal(t, "Unable to parse URL extracted from X-Original-URL header: parse \"htt-ps//home?-.example.com\": invalid URI for request", err.Error()) + assert.EqualError(t, err, "failed to parse X-Original-URL header: parse \"htt-ps//home?-.example.com\": invalid URI for request") } func TestShouldFallbackToNonXForwardedHeaders(t *testing.T) { @@ -190,8 +190,8 @@ func TestShouldFallbackToNonXForwardedHeaders(t *testing.T) { mock.Ctx.RequestCtx.Request.SetHost("auth.example.com:1234") assert.Equal(t, []byte("http"), mock.Ctx.XForwardedProto()) - assert.Equal(t, []byte("auth.example.com:1234"), mock.Ctx.XForwardedHost()) - assert.Equal(t, []byte("/2fa/one-time-password"), mock.Ctx.XForwardedURI()) + assert.Equal(t, []byte("auth.example.com:1234"), mock.Ctx.GetXForwardedHost()) + assert.Equal(t, []byte("/2fa/one-time-password"), mock.Ctx.GetXForwardedURI()) } func TestShouldOnlyFallbackToNonXForwardedHeadersWhenNil(t *testing.T) { @@ -208,8 +208,8 @@ func TestShouldOnlyFallbackToNonXForwardedHeadersWhenNil(t *testing.T) { mock.Ctx.RequestCtx.Request.Header.Set("X-Forwarded-Method", "GET") assert.Equal(t, []byte("https"), mock.Ctx.XForwardedProto()) - assert.Equal(t, []byte("auth.example.com:1234"), mock.Ctx.XForwardedHost()) - assert.Equal(t, []byte("/base/2fa/one-time-password"), mock.Ctx.XForwardedURI()) + assert.Equal(t, []byte("auth.example.com:1234"), mock.Ctx.GetXForwardedHost()) + assert.Equal(t, []byte("/base/2fa/one-time-password"), mock.Ctx.GetXForwardedURI()) assert.Equal(t, []byte("GET"), mock.Ctx.XForwardedMethod()) } diff --git a/internal/middlewares/const.go b/internal/middlewares/const.go index dc5519f2e..5332b6317 100644 --- a/internal/middlewares/const.go +++ b/internal/middlewares/const.go @@ -71,18 +71,20 @@ const ( strProtoHTTP = "http" strSlash = "/" - queryArgRedirect = "rd" - queryArgToken = "token" + queryArgRedirect = "rd" + queryArgAutheliaURL = "authelia_url" + queryArgToken = "token" ) var ( protoHTTPS = []byte(strProtoHTTPS) protoHTTP = []byte(strProtoHTTP) - qryArgRedirect = []byte(queryArgRedirect) + qryArgRedirect = []byte(queryArgRedirect) + qryArgAutheliaURL = []byte(queryArgAutheliaURL) - // UserValueKeyBaseURL is the User Value key where we store the Base URL. - UserValueKeyBaseURL = []byte("base_url") + keyUserValueBaseURL = []byte("base_url") + keyUserValueAuthzPath = []byte("authz_path") // UserValueKeyFormPost is the User Value key where we indicate the form_post response mode. UserValueKeyFormPost = []byte("form_post") diff --git a/internal/middlewares/errors.go b/internal/middlewares/errors.go index d1d892b80..5a9e4a64b 100644 --- a/internal/middlewares/errors.go +++ b/internal/middlewares/errors.go @@ -2,5 +2,16 @@ package middlewares import "errors" -var errMissingXForwardedHost = errors.New("Missing header X-Forwarded-Host") -var errMissingXForwardedProto = errors.New("Missing header X-Forwarded-Proto") +var ( + // ErrMissingXForwardedProto is returned on methods which require an X-Forwarded-Proto header. + ErrMissingXForwardedProto = errors.New("missing required X-Forwarded-Proto header") + + // ErrMissingXForwardedHost is returned on methods which require an X-Forwarded-Host header. + ErrMissingXForwardedHost = errors.New("missing required X-Forwarded-Host header") + + // ErrMissingHeaderHost is returned on methods which require an Host header. + ErrMissingHeaderHost = errors.New("missing required Host header") + + // ErrMissingXOriginalURL is returned on methods which require an X-Original-URL header. + ErrMissingXOriginalURL = errors.New("missing required X-Original-URL header") +) diff --git a/internal/middlewares/metrics.go b/internal/middlewares/metrics.go index f908cc4ec..7f58cac8b 100644 --- a/internal/middlewares/metrics.go +++ b/internal/middlewares/metrics.go @@ -29,8 +29,8 @@ func NewMetricsRequest(metrics metrics.Recorder) (middleware Basic) { } } -// NewMetricsVerifyRequest returns a middleware if provided with a metrics.Recorder, otherwise it returns nil. -func NewMetricsVerifyRequest(metrics metrics.Recorder) (middleware Basic) { +// NewMetricsAuthzRequest returns a middleware if provided with a metrics.Recorder, otherwise it returns nil. +func NewMetricsAuthzRequest(metrics metrics.Recorder) (middleware Basic) { if metrics == nil { return nil } @@ -41,7 +41,7 @@ func NewMetricsVerifyRequest(metrics metrics.Recorder) (middleware Basic) { statusCode := strconv.Itoa(ctx.Response.StatusCode()) - metrics.RecordVerifyRequest(statusCode) + metrics.RecordAuthz(statusCode) } } } diff --git a/internal/middlewares/require_authentication_level.go b/internal/middlewares/require_authentication_level.go index 7384685a0..2553a4417 100644 --- a/internal/middlewares/require_authentication_level.go +++ b/internal/middlewares/require_authentication_level.go @@ -9,7 +9,7 @@ import ( // Require1FA requires the user to have authenticated with at least one-factor authentication (i.e. password). func Require1FA(next RequestHandler) RequestHandler { return func(ctx *AutheliaCtx) { - if ctx.GetSession().AuthenticationLevel < authentication.OneFactor { + if session, err := ctx.GetSession(); err != nil || session.AuthenticationLevel < authentication.OneFactor { ctx.ReplyForbidden() return } @@ -21,7 +21,7 @@ func Require1FA(next RequestHandler) RequestHandler { // Require2FA requires the user to have authenticated with two-factor authentication. func Require2FA(next RequestHandler) RequestHandler { return func(ctx *AutheliaCtx) { - if ctx.GetSession().AuthenticationLevel < authentication.TwoFactor { + if session, err := ctx.GetSession(); err != nil || session.AuthenticationLevel < authentication.TwoFactor { ctx.ReplyForbidden() return } @@ -33,7 +33,7 @@ func Require2FA(next RequestHandler) RequestHandler { // Require2FAWithAPIResponse requires the user to have authenticated with two-factor authentication. func Require2FAWithAPIResponse(next RequestHandler) RequestHandler { return func(ctx *AutheliaCtx) { - if ctx.GetSession().AuthenticationLevel < authentication.TwoFactor { + if session, err := ctx.GetSession(); err != nil || session.AuthenticationLevel < authentication.TwoFactor { ctx.SetAuthenticationErrorJSON(fasthttp.StatusForbidden, "Authentication Required.", true, false) return } diff --git a/internal/middlewares/strip_path.go b/internal/middlewares/strip_path.go index 7ebfc3549..8b53f80db 100644 --- a/internal/middlewares/strip_path.go +++ b/internal/middlewares/strip_path.go @@ -13,7 +13,7 @@ func StripPath(path string) (middleware Middleware) { uri := ctx.RequestURI() if strings.HasPrefix(string(uri), path) { - ctx.SetUserValueBytes(UserValueKeyBaseURL, path) + ctx.SetUserValueBytes(keyUserValueBaseURL, path) newURI := strings.TrimPrefix(string(uri), path) ctx.Request.SetRequestURI(newURI) diff --git a/internal/middlewares/types.go b/internal/middlewares/types.go index 175bb0e69..ddb88c609 100644 --- a/internal/middlewares/types.go +++ b/internal/middlewares/types.go @@ -29,6 +29,8 @@ type AutheliaCtx struct { Configuration schema.Configuration Clock utils.Clock + + session *session.Session } // Providers contain all provider provided to Authelia. diff --git a/internal/mocks/authelia_ctx.go b/internal/mocks/authelia_ctx.go index 17f742648..486beeac9 100644 --- a/internal/mocks/authelia_ctx.go +++ b/internal/mocks/authelia_ctx.go @@ -70,28 +70,116 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx { }, } - config.AccessControl.DefaultPolicy = "deny" - config.AccessControl.Rules = []schema.ACLRule{{ - Domains: []string{"bypass.example.com"}, - Policy: "bypass", - }, { - Domains: []string{"one-factor.example.com"}, - Policy: "one_factor", - }, { - Domains: []string{"two-factor.example.com"}, - Policy: "two_factor", - }, { - Domains: []string{"deny.example.com"}, - Policy: "deny", - }, { - Domains: []string{"admin.example.com"}, - Policy: "two_factor", - Subjects: [][]string{{"group:admin"}}, - }, { - Domains: []string{"grafana.example.com"}, - Policy: "two_factor", - Subjects: [][]string{{"group:grafana"}}, - }} + config.AccessControl = schema.AccessControlConfiguration{ + DefaultPolicy: "deny", + Rules: []schema.ACLRule{ + { + Domains: []string{"bypass.example.com"}, + Policy: "bypass", + }, + { + Domains: []string{"bypass-get.example.com"}, + Policy: "bypass", + Methods: []string{fasthttp.MethodGet}, + }, + { + Domains: []string{"bypass-head.example.com"}, + Policy: "bypass", + Methods: []string{fasthttp.MethodHead}, + }, + { + Domains: []string{"bypass-options.example.com"}, + Policy: "bypass", + Methods: []string{fasthttp.MethodOptions}, + }, + { + Domains: []string{"bypass-trace.example.com"}, + Policy: "bypass", + Methods: []string{fasthttp.MethodTrace}, + }, + { + Domains: []string{"bypass-put.example.com"}, + Policy: "bypass", + Methods: []string{fasthttp.MethodPut}, + }, + { + Domains: []string{"bypass-patch.example.com"}, + Policy: "bypass", + Methods: []string{fasthttp.MethodPatch}, + }, + { + Domains: []string{"bypass-post.example.com"}, + Policy: "bypass", + Methods: []string{fasthttp.MethodPost}, + }, + { + Domains: []string{"bypass-delete.example.com"}, + Policy: "bypass", + Methods: []string{fasthttp.MethodDelete}, + }, + { + Domains: []string{"bypass-connect.example.com"}, + Policy: "bypass", + Methods: []string{fasthttp.MethodConnect}, + }, + { + Domains: []string{ + "bypass-get.example.com", "bypass-head.example.com", "bypass-options.example.com", + "bypass-trace.example.com", "bypass-put.example.com", "bypass-patch.example.com", + "bypass-post.example.com", "bypass-delete.example.com", "bypass-connect.example.com", + }, + Policy: "one_factor", + }, + { + Domains: []string{"one-factor.example.com"}, + Policy: "one_factor", + }, + { + Domains: []string{"two-factor.example.com"}, + Policy: "two_factor", + }, + { + Domains: []string{"deny.example.com"}, + Policy: "deny", + }, + { + Domains: []string{"admin.example.com"}, + Policy: "two_factor", + Subjects: [][]string{{"group:admin"}}, + }, + { + Domains: []string{"grafana.example.com"}, + Policy: "two_factor", + Subjects: [][]string{{"group:grafana"}}, + }, + { + Domains: []string{"bypass.example2.com"}, + Policy: "bypass", + }, + { + Domains: []string{"one-factor.example2.com"}, + Policy: "one_factor", + }, + { + Domains: []string{"two-factor.example2.com"}, + Policy: "two_factor", + }, + { + Domains: []string{"deny.example2.com"}, + Policy: "deny", + }, + { + Domains: []string{"admin.example2.com"}, + Policy: "two_factor", + Subjects: [][]string{{"group:admin"}}, + }, + { + Domains: []string{"grafana.example2.com"}, + Policy: "two_factor", + Subjects: [][]string{{"group:grafana"}}, + }, + }, + } providers := middlewares.Providers{} diff --git a/internal/mocks/duo_api.go b/internal/mocks/duo_api.go index d5a753305..d358f9c74 100644 --- a/internal/mocks/duo_api.go +++ b/internal/mocks/duo_api.go @@ -8,10 +8,10 @@ import ( url "net/url" reflect "reflect" - gomock "github.com/golang/mock/gomock" - duo "github.com/authelia/authelia/v4/internal/duo" middlewares "github.com/authelia/authelia/v4/internal/middlewares" + session "github.com/authelia/authelia/v4/internal/session" + gomock "github.com/golang/mock/gomock" ) // MockAPI is a mock of API interface. @@ -38,46 +38,46 @@ func (m *MockAPI) EXPECT() *MockAPIMockRecorder { } // AuthCall mocks base method. -func (m *MockAPI) AuthCall(arg0 *middlewares.AutheliaCtx, arg1 url.Values) (*duo.AuthResponse, error) { +func (m *MockAPI) AuthCall(arg0 *middlewares.AutheliaCtx, arg1 *session.UserSession, arg2 url.Values) (*duo.AuthResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AuthCall", arg0, arg1) + ret := m.ctrl.Call(m, "AuthCall", arg0, arg1, arg2) ret0, _ := ret[0].(*duo.AuthResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // AuthCall indicates an expected call of AuthCall. -func (mr *MockAPIMockRecorder) AuthCall(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockAPIMockRecorder) AuthCall(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthCall", reflect.TypeOf((*MockAPI)(nil).AuthCall), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthCall", reflect.TypeOf((*MockAPI)(nil).AuthCall), arg0, arg1, arg2) } // Call mocks base method. -func (m *MockAPI) Call(arg0 *middlewares.AutheliaCtx, arg1 url.Values, arg2, arg3 string) (*duo.Response, error) { +func (m *MockAPI) Call(arg0 *middlewares.AutheliaCtx, arg1 *session.UserSession, arg2 url.Values, arg3, arg4 string) (*duo.Response, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Call", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "Call", arg0, arg1, arg2, arg3, arg4) ret0, _ := ret[0].(*duo.Response) ret1, _ := ret[1].(error) return ret0, ret1 } // Call indicates an expected call of Call. -func (mr *MockAPIMockRecorder) Call(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockAPIMockRecorder) Call(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockAPI)(nil).Call), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockAPI)(nil).Call), arg0, arg1, arg2, arg3, arg4) } // PreAuthCall mocks base method. -func (m *MockAPI) PreAuthCall(arg0 *middlewares.AutheliaCtx, arg1 url.Values) (*duo.PreAuthResponse, error) { +func (m *MockAPI) PreAuthCall(arg0 *middlewares.AutheliaCtx, arg1 *session.UserSession, arg2 url.Values) (*duo.PreAuthResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PreAuthCall", arg0, arg1) + ret := m.ctrl.Call(m, "PreAuthCall", arg0, arg1, arg2) ret0, _ := ret[0].(*duo.PreAuthResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // PreAuthCall indicates an expected call of PreAuthCall. -func (mr *MockAPIMockRecorder) PreAuthCall(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockAPIMockRecorder) PreAuthCall(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreAuthCall", reflect.TypeOf((*MockAPI)(nil).PreAuthCall), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreAuthCall", reflect.TypeOf((*MockAPI)(nil).PreAuthCall), arg0, arg1, arg2) } diff --git a/internal/regulation/regulator.go b/internal/regulation/regulator.go index e7e2ba37a..c57cabdcd 100644 --- a/internal/regulation/regulator.go +++ b/internal/regulation/regulator.go @@ -24,7 +24,7 @@ func NewRegulator(config schema.RegulationConfiguration, provider storage.Regula // Mark an authentication attempt. // We split Mark and Regulate in order to avoid timing attacks. func (r *Regulator) Mark(ctx Context, successful, banned bool, username, requestURI, requestMethod, authType string) error { - ctx.RecordAuthentication(successful, banned, strings.ToLower(authType)) + ctx.RecordAuthn(successful, banned, strings.ToLower(authType)) return r.storageProvider.AppendAuthenticationLog(ctx, model.AuthenticationAttempt{ Time: r.clock.Now(), diff --git a/internal/regulation/types.go b/internal/regulation/types.go index 3e902a78d..d5ad21edd 100644 --- a/internal/regulation/types.go +++ b/internal/regulation/types.go @@ -31,5 +31,5 @@ type Context interface { // MetricsRecorder represents the methods used to record regulation. type MetricsRecorder interface { - RecordAuthentication(success, banned bool, authType string) + RecordAuthn(success, banned bool, authType string) } diff --git a/internal/server/const.go b/internal/server/const.go index d4842f152..069797b65 100644 --- a/internal/server/const.go +++ b/internal/server/const.go @@ -14,6 +14,12 @@ const ( extYML = ".yml" ) +const ( + pathAuthz = "/api/authz" + pathAuthzLegacy = "/api/verify" + pathParamAuthzEnvoy = "{authz_path:*}" +) + var ( filesRoot = []string{"manifest.json", "robots.txt"} filesSwagger = []string{ diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 9992f40f5..47e5217d7 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -3,6 +3,7 @@ package server import ( "net" "os" + "path" "strings" "time" @@ -90,7 +91,10 @@ func handleNotFound(next fasthttp.RequestHandler) fasthttp.RequestHandler { } } +//nolint:gocyclo func handleRouter(config schema.Configuration, providers middlewares.Providers) fasthttp.RequestHandler { + log := logging.Logger() + optsTemplatedFile := NewTemplatedFileOptions(&config) serveIndexHandler := ServeTemplatedFile(providers.Templates.GetAssetIndexTemplate(), optsTemplatedFile) @@ -100,7 +104,7 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers) handlerPublicHTML := newPublicHTMLEmbeddedHandler() handlerLocales := newLocalesEmbeddedHandler() - middleware := middlewares.NewBridgeBuilder(config, providers). + bridge := middlewares.NewBridgeBuilder(config, providers). WithPreMiddlewares(middlewares.SecurityHeaders).Build() policyCORSPublicGET := middlewares.NewCORSPolicyBuilder(). @@ -111,7 +115,7 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers) r := router.New() // Static Assets. - r.GET("/", middleware(serveIndexHandler)) + r.GET("/", bridge(serveIndexHandler)) for _, f := range filesRoot { r.GET("/"+f, handlerPublicHTML) @@ -126,11 +130,11 @@ 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(serveOpenAPIHandler)) + r.GET("/api/", bridge(serveOpenAPIHandler)) r.OPTIONS("/api/", policyCORSPublicGET.HandleOPTIONS) - r.GET("/api/index.html", middleware(serveOpenAPIHandler)) + r.GET("/api/index.html", bridge(serveOpenAPIHandler)) r.OPTIONS("/api/index.html", policyCORSPublicGET.HandleOPTIONS) - r.GET("/api/openapi.yml", policyCORSPublicGET.Middleware(middleware(serveOpenAPISpecHandler))) + r.GET("/api/openapi.yml", policyCORSPublicGET.Middleware(bridge(serveOpenAPISpecHandler))) r.OPTIONS("/api/openapi.yml", policyCORSPublicGET.HandleOPTIONS) for _, file := range filesSwagger { @@ -158,10 +162,48 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers) r.GET("/api/configuration/password-policy", middlewareAPI(handlers.PasswordPolicyConfigurationGET)) - metricsVRMW := middlewares.NewMetricsVerifyRequest(providers.Metrics) + metricsVRMW := middlewares.NewMetricsAuthzRequest(providers.Metrics) - r.ANY("/api/verify", middlewares.Wrap(metricsVRMW, middleware(handlers.VerifyGET(config.AuthenticationBackend)))) - r.ANY("/api/verify/{path:*}", middlewares.Wrap(metricsVRMW, middleware(handlers.VerifyGET(config.AuthenticationBackend)))) + for name, endpoint := range config.Server.Endpoints.Authz { + uri := path.Join(pathAuthz, name) + + authz := handlers.NewAuthzBuilder().WithConfig(&config).WithEndpointConfig(endpoint).Build() + + handler := middlewares.Wrap(metricsVRMW, bridge(authz.Handler)) + + switch name { + case "legacy": + log. + WithField("path_prefix", pathAuthzLegacy). + WithField("impl", endpoint.Implementation). + WithField("methods", []string{"*"}). + Trace("Registering Authz Endpoint") + + r.ANY(pathAuthzLegacy, handler) + r.ANY(path.Join(pathAuthzLegacy, pathParamAuthzEnvoy), handler) + default: + switch endpoint.Implementation { + case handlers.AuthzImplLegacy.String(), handlers.AuthzImplExtAuthz.String(): + log. + WithField("path_prefix", uri). + WithField("impl", endpoint.Implementation). + WithField("methods", []string{"*"}). + Trace("Registering Authz Endpoint") + + r.ANY(uri, handler) + r.ANY(path.Join(uri, pathParamAuthzEnvoy), handler) + default: + log. + WithField("path", uri). + WithField("impl", endpoint.Implementation). + WithField("methods", []string{fasthttp.MethodGet, fasthttp.MethodHead}). + Trace("Registering Authz Endpoint") + + r.GET(uri, handler) + r.HEAD(uri, handler) + } + } + } r.POST("/api/checks/safe-redirection", middlewareAPI(handlers.CheckSafeRedirectionPOST)) @@ -227,11 +269,11 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers) r.POST("/api/secondfactor/duo_device", middleware1FA(handlers.DuoDevicePOST)) } - if config.Server.EnablePprof { + if config.Server.Endpoints.EnablePprof { r.GET("/debug/pprof/{name?}", pprofhandler.PprofHandler) } - if config.Server.EnableExpvars { + if config.Server.Endpoints.EnableExpvars { r.GET("/debug/vars", expvarhandler.ExpvarHandler) } @@ -325,7 +367,7 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers) r.HandleMethodNotAllowed = true r.MethodNotAllowed = handlers.Status(fasthttp.StatusMethodNotAllowed) - r.NotFound = handleNotFound(middleware(serveIndexHandler)) + r.NotFound = handleNotFound(bridge(serveIndexHandler)) handler := middlewares.LogRequest(r.Handler) if config.Server.Path != "" { diff --git a/internal/server/locales/en/portal.json b/internal/server/locales/en/portal.json index 4cce09100..0a9065c52 100644 --- a/internal/server/locales/en/portal.json +++ b/internal/server/locales/en/portal.json @@ -39,6 +39,7 @@ "Password": "Password", "Passwords do not match": "Passwords do not match.", "Powered by": "Powered by", + "Privacy Policy": "Privacy Policy", "Push Notification": "Push Notification", "Register device": "Register device", "Register your first device by clicking on the link below": "Register your first device by clicking on the link below.", @@ -67,6 +68,7 @@ "Use OpenID to verify your identity": "Use OpenID to verify your identity", "Username": "Username", "You must open the link from the same device and browser that initiated the registration process": "You must open the link from the same device and browser that initiated the registration process", + "You must view and accept the Privacy Policy before using": "You must view and accept the <0>Privacy Policy before using", "You're being signed out and redirected": "You're being signed out and redirected", "Your supplied password does not meet the password policy requirements": "Your supplied password does not meet the password policy requirements." } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index dd87678e5..9b6dfa992 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "os" + "path" "strconv" "strings" "testing" @@ -25,6 +26,12 @@ import ( "github.com/authelia/authelia/v4/internal/utils" ) +func Test(t *testing.T) { + fmt.Println(path.Join("/api/authz/", "abc")) + fmt.Println(path.Join("/api/authz/", "abc/123/", "{path:*}")) + fmt.Println(path.Join("/api/authz/", "abc/123/")) +} + // TemporaryCertificate contains the FD of 2 temporary files containing the PEM format of the certificate and private key. type TemporaryCertificate struct { CertFile *os.File @@ -190,7 +197,7 @@ func TestShouldRaiseErrorWhenClientDoesNotSkipVerify(t *testing.T) { tlsServerContext, err := NewTLSServerContext(schema.Configuration{ Server: schema.ServerConfiguration{ - TLS: schema.ServerTLSConfiguration{ + TLS: schema.ServerTLS{ Certificate: certificateContext.Certificates[0].CertFile.Name(), Key: certificateContext.Certificates[0].KeyFile.Name(), }, @@ -218,7 +225,7 @@ func TestShouldServeOverTLSWhenClientDoesSkipVerify(t *testing.T) { tlsServerContext, err := NewTLSServerContext(schema.Configuration{ Server: schema.ServerConfiguration{ - TLS: schema.ServerTLSConfiguration{ + TLS: schema.ServerTLS{ Certificate: certificateContext.Certificates[0].CertFile.Name(), Key: certificateContext.Certificates[0].KeyFile.Name(), }, @@ -255,7 +262,7 @@ func TestShouldServeOverTLSWhenClientHasProperRootCA(t *testing.T) { tlsServerContext, err := NewTLSServerContext(schema.Configuration{ Server: schema.ServerConfiguration{ - TLS: schema.ServerTLSConfiguration{ + TLS: schema.ServerTLS{ Certificate: certificateContext.Certificates[0].CertFile.Name(), Key: certificateContext.Certificates[0].KeyFile.Name(), }, @@ -306,7 +313,7 @@ func TestShouldRaiseWhenMutualTLSIsConfiguredAndClientIsNotAuthenticated(t *test tlsServerContext, err := NewTLSServerContext(schema.Configuration{ Server: schema.ServerConfiguration{ - TLS: schema.ServerTLSConfiguration{ + TLS: schema.ServerTLS{ Certificate: certificateContext.Certificates[0].CertFile.Name(), Key: certificateContext.Certificates[0].KeyFile.Name(), ClientCertificates: []string{clientCert.CertFile.Name()}, @@ -349,7 +356,7 @@ func TestShouldServeProperlyWhenMutualTLSIsConfiguredAndClientIsAuthenticated(t tlsServerContext, err := NewTLSServerContext(schema.Configuration{ Server: schema.ServerConfiguration{ - TLS: schema.ServerTLSConfiguration{ + TLS: schema.ServerTLS{ Certificate: certificateContext.Certificates[0].CertFile.Name(), Key: certificateContext.Certificates[0].KeyFile.Name(), ClientCertificates: []string{clientCert.CertFile.Name()}, diff --git a/internal/server/template.go b/internal/server/template.go index aa24f4b99..3e4de786c 100644 --- a/internal/server/template.go +++ b/internal/server/template.go @@ -210,6 +210,12 @@ func NewTemplatedFileOptions(config *schema.Configuration) (opts *TemplatedFileO EndpointsTOTP: !config.TOTP.Disable, EndpointsDuo: !config.DuoAPI.Disable, EndpointsOpenIDConnect: !(config.IdentityProviders.OIDC == nil), + EndpointsAuthz: config.Server.Endpoints.Authz, + } + + if config.PrivacyPolicy.Enabled { + opts.PrivacyPolicyURL = config.PrivacyPolicy.PolicyURL.String() + opts.PrivacyPolicyAccept = strconv.FormatBool(config.PrivacyPolicy.RequireUserAcceptance) } if !config.DuoAPI.Disable { @@ -226,6 +232,8 @@ type TemplatedFileOptions struct { RememberMe string ResetPassword string ResetPasswordCustomURL string + PrivacyPolicyURL string + PrivacyPolicyAccept string Session string Theme string @@ -234,6 +242,8 @@ type TemplatedFileOptions struct { EndpointsTOTP bool EndpointsDuo bool EndpointsOpenIDConnect bool + + EndpointsAuthz map[string]schema.ServerAuthzEndpoint } // CommonData returns a TemplatedFileCommonData with the dynamic options. @@ -251,6 +261,8 @@ func (options *TemplatedFileOptions) CommonData(base, baseURL, nonce, logoOverri RememberMe: options.RememberMe, ResetPassword: options.ResetPassword, ResetPasswordCustomURL: options.ResetPasswordCustomURL, + PrivacyPolicyURL: options.PrivacyPolicyURL, + PrivacyPolicyAccept: options.PrivacyPolicyAccept, Session: options.Session, Theme: options.Theme, } @@ -279,12 +291,13 @@ func (options *TemplatedFileOptions) OpenAPIData(base, baseURL, nonce string) Te BaseURL: baseURL, CSPNonce: nonce, - Session: options.Session, - PasswordReset: options.EndpointsPasswordReset, - Webauthn: options.EndpointsWebauthn, - TOTP: options.EndpointsTOTP, - Duo: options.EndpointsDuo, - OpenIDConnect: options.EndpointsOpenIDConnect, + Session: options.Session, + PasswordReset: options.EndpointsPasswordReset, + Webauthn: options.EndpointsWebauthn, + TOTP: options.EndpointsTOTP, + Duo: options.EndpointsDuo, + OpenIDConnect: options.EndpointsOpenIDConnect, + EndpointsAuthz: options.EndpointsAuthz, } } @@ -298,6 +311,8 @@ type TemplatedFileCommonData struct { RememberMe string ResetPassword string ResetPasswordCustomURL string + PrivacyPolicyURL string + PrivacyPolicyAccept string Session string Theme string } @@ -313,4 +328,6 @@ type TemplatedFileOpenAPIData struct { TOTP bool Duo bool OpenIDConnect bool + + EndpointsAuthz map[string]schema.ServerAuthzEndpoint } diff --git a/internal/session/provider_test.go b/internal/session/provider_test.go index 81eab3cff..4073ad546 100644 --- a/internal/session/provider_test.go +++ b/internal/session/provider_test.go @@ -39,7 +39,7 @@ func TestShouldInitializerSession(t *testing.T) { session, err := provider.GetSession(ctx) assert.NoError(t, err) - assert.Equal(t, NewDefaultUserSession(), session) + assert.Equal(t, provider.NewDefaultUserSession(), session) } func TestShouldUpdateSession(t *testing.T) { @@ -60,6 +60,7 @@ func TestShouldUpdateSession(t *testing.T) { assert.NoError(t, err) assert.Equal(t, UserSession{ + CookieDomain: testDomain, Username: testUsername, AuthenticationLevel: authentication.TwoFactor, }, session) @@ -98,6 +99,7 @@ func TestShouldSetSessionAuthenticationLevels(t *testing.T) { assert.Equal(t, timeZeroFactor, authAt) assert.Equal(t, UserSession{ + CookieDomain: testDomain, Username: testUsername, AuthenticationLevel: authentication.OneFactor, LastActivity: timeOneFactor.Unix(), @@ -114,6 +116,7 @@ func TestShouldSetSessionAuthenticationLevels(t *testing.T) { assert.NoError(t, err) assert.Equal(t, UserSession{ + CookieDomain: testDomain, Username: testUsername, AuthenticationLevel: authentication.TwoFactor, LastActivity: timeTwoFactor.Unix(), @@ -168,6 +171,7 @@ func TestShouldSetSessionAuthenticationLevelsAMR(t *testing.T) { assert.Equal(t, timeZeroFactor, authAt) assert.Equal(t, UserSession{ + CookieDomain: testDomain, Username: testUsername, AuthenticationLevel: authentication.OneFactor, LastActivity: timeOneFactor.Unix(), diff --git a/internal/session/session.go b/internal/session/session.go index 89c32830e..5d4e9577e 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -4,7 +4,7 @@ import ( "encoding/json" "time" - fasthttpsession "github.com/fasthttp/session/v2" + "github.com/fasthttp/session/v2" "github.com/valyala/fasthttp" "github.com/authelia/authelia/v4/internal/configuration/schema" @@ -14,15 +14,24 @@ import ( type Session struct { Config schema.SessionCookieConfiguration - sessionHolder *fasthttpsession.Session + sessionHolder *session.Session +} + +// NewDefaultUserSession returns a new default UserSession for this session provider. +func (p *Session) NewDefaultUserSession() (userSession UserSession) { + userSession = NewDefaultUserSession() + + userSession.CookieDomain = p.Config.Domain + + return userSession } // GetSession return the user session from a request. -func (p *Session) GetSession(ctx *fasthttp.RequestCtx) (UserSession, error) { - store, err := p.sessionHolder.Get(ctx) +func (p *Session) GetSession(ctx *fasthttp.RequestCtx) (userSession UserSession, err error) { + var store *session.Store - if err != nil { - return NewDefaultUserSession(), err + if store, err = p.sessionHolder.Get(ctx); err != nil { + return p.NewDefaultUserSession(), err } userSessionJSON, ok := store.Get(userSessionStorerKey).([]byte) @@ -30,42 +39,38 @@ func (p *Session) GetSession(ctx *fasthttp.RequestCtx) (UserSession, error) { // If userSession is not yet defined we create the new session with default values // and save it in the store. if !ok { - userSession := NewDefaultUserSession() + userSession = p.NewDefaultUserSession() store.Set(userSessionStorerKey, userSession) return userSession, nil } - var userSession UserSession - err = json.Unmarshal(userSessionJSON, &userSession) - - if err != nil { - return NewDefaultUserSession(), err + if err = json.Unmarshal(userSessionJSON, &userSession); err != nil { + return p.NewDefaultUserSession(), err } return userSession, nil } // SaveSession save the user session. -func (p *Session) SaveSession(ctx *fasthttp.RequestCtx, userSession UserSession) error { - store, err := p.sessionHolder.Get(ctx) +func (p *Session) SaveSession(ctx *fasthttp.RequestCtx, userSession UserSession) (err error) { + var ( + store *session.Store + userSessionJSON []byte + ) - if err != nil { + if store, err = p.sessionHolder.Get(ctx); err != nil { return err } - userSessionJSON, err := json.Marshal(userSession) - - if err != nil { + if userSessionJSON, err = json.Marshal(userSession); err != nil { return err } store.Set(userSessionStorerKey, userSessionJSON) - err = p.sessionHolder.Save(ctx, store) - - if err != nil { + if err = p.sessionHolder.Save(ctx, store); err != nil { return err } @@ -74,9 +79,7 @@ func (p *Session) SaveSession(ctx *fasthttp.RequestCtx, userSession UserSession) // RegenerateSession regenerate a session ID. func (p *Session) RegenerateSession(ctx *fasthttp.RequestCtx) error { - err := p.sessionHolder.Regenerate(ctx) - - return err + return p.sessionHolder.Regenerate(ctx) } // DestroySession destroy a session ID and delete the cookie. @@ -85,10 +88,10 @@ func (p *Session) DestroySession(ctx *fasthttp.RequestCtx) error { } // UpdateExpiration update the expiration of the cookie and session. -func (p *Session) UpdateExpiration(ctx *fasthttp.RequestCtx, expiration time.Duration) error { - store, err := p.sessionHolder.Get(ctx) +func (p *Session) UpdateExpiration(ctx *fasthttp.RequestCtx, expiration time.Duration) (err error) { + var store *session.Store - if err != nil { + if store, err = p.sessionHolder.Get(ctx); err != nil { return err } diff --git a/internal/session/types.go b/internal/session/types.go index f256da2cb..9fc7bd5b4 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -18,6 +18,8 @@ type ProviderConfig struct { // UserSession is the structure representing the session of a user. type UserSession struct { + CookieDomain string + Username string DisplayName string // TODO(c.michaud): move groups out of the session. diff --git a/internal/suites/BypassAll/configuration.yml b/internal/suites/BypassAll/configuration.yml index e2b592a5f..8e5157fdc 100644 --- a/internal/suites/BypassAll/configuration.yml +++ b/internal/suites/BypassAll/configuration.yml @@ -20,10 +20,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/CLI/configuration.yml b/internal/suites/CLI/configuration.yml index 99a1aee7d..a01f2a06b 100644 --- a/internal/suites/CLI/configuration.yml +++ b/internal/suites/CLI/configuration.yml @@ -23,7 +23,7 @@ session: cookies: - name: 'authelia_session' domain: 'example.com' - authelia_url: 'https://login.example.com' + authelia_url: 'https://login.example.com:8080' expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y diff --git a/internal/suites/Caddy/configuration.yml b/internal/suites/Caddy/configuration.yml index 4ce6a5b4d..38031dcdc 100644 --- a/internal/suites/Caddy/configuration.yml +++ b/internal/suites/Caddy/configuration.yml @@ -11,6 +11,11 @@ server: tls: certificate: /config/ssl/cert.pem key: /config/ssl/key.pem + endpoints: + authz: + caddy: + implementation: ForwardAuth + authn_strategies: [] log: level: debug @@ -21,10 +26,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/Docker/configuration.yml b/internal/suites/Docker/configuration.yml index d37132c67..095e9fdb1 100644 --- a/internal/suites/Docker/configuration.yml +++ b/internal/suites/Docker/configuration.yml @@ -21,10 +21,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/DuoPush/configuration.yml b/internal/suites/DuoPush/configuration.yml index cb8caaec0..79c40ec3a 100644 --- a/internal/suites/DuoPush/configuration.yml +++ b/internal/suites/DuoPush/configuration.yml @@ -21,10 +21,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/Envoy/configuration.yml b/internal/suites/Envoy/configuration.yml index e1a05f80d..6a5ff2021 100644 --- a/internal/suites/Envoy/configuration.yml +++ b/internal/suites/Envoy/configuration.yml @@ -11,6 +11,11 @@ server: tls: certificate: /config/ssl/cert.pem key: /config/ssl/key.pem + endpoints: + authz: + ext-authz: + implementation: ExtAuthz + authn_strategies: [] log: level: debug @@ -27,6 +32,7 @@ session: cookies: - name: 'authelia_session' domain: 'example.com' + authelia_url: 'https://login.example.com:8080/' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/HAProxy/configuration.yml b/internal/suites/HAProxy/configuration.yml index 1de216395..ddbe9e58e 100644 --- a/internal/suites/HAProxy/configuration.yml +++ b/internal/suites/HAProxy/configuration.yml @@ -20,10 +20,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/LDAP/configuration.yml b/internal/suites/LDAP/configuration.yml index f69a46c7c..120e89224 100644 --- a/internal/suites/LDAP/configuration.yml +++ b/internal/suites/LDAP/configuration.yml @@ -35,10 +35,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/MariaDB/configuration.yml b/internal/suites/MariaDB/configuration.yml index 3228275b3..e575b1408 100644 --- a/internal/suites/MariaDB/configuration.yml +++ b/internal/suites/MariaDB/configuration.yml @@ -21,10 +21,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/MySQL/configuration.yml b/internal/suites/MySQL/configuration.yml index cf8742d0b..7dd065be9 100644 --- a/internal/suites/MySQL/configuration.yml +++ b/internal/suites/MySQL/configuration.yml @@ -22,10 +22,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/NetworkACL/configuration.yml b/internal/suites/NetworkACL/configuration.yml index 5bdf2fa87..c07cfe3ee 100644 --- a/internal/suites/NetworkACL/configuration.yml +++ b/internal/suites/NetworkACL/configuration.yml @@ -20,10 +20,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/OIDC/configuration.yml b/internal/suites/OIDC/configuration.yml index 543c6da38..0b1d17f2f 100644 --- a/internal/suites/OIDC/configuration.yml +++ b/internal/suites/OIDC/configuration.yml @@ -18,7 +18,8 @@ session: secret: unsecure_session_secret cookies: - - domain: example.com + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y diff --git a/internal/suites/OIDCTraefik/configuration.yml b/internal/suites/OIDCTraefik/configuration.yml index 367dc4e2b..2e089fda9 100644 --- a/internal/suites/OIDCTraefik/configuration.yml +++ b/internal/suites/OIDCTraefik/configuration.yml @@ -16,10 +16,13 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' + # We use redis here to keep the users authenticated when Authelia restarts # It eases development. redis: diff --git a/internal/suites/OneFactorOnly/configuration.yml b/internal/suites/OneFactorOnly/configuration.yml index 203a803bd..bc3ac4485 100644 --- a/internal/suites/OneFactorOnly/configuration.yml +++ b/internal/suites/OneFactorOnly/configuration.yml @@ -21,10 +21,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/PathPrefix/configuration.yml b/internal/suites/PathPrefix/configuration.yml index 323187c5e..decb58436 100644 --- a/internal/suites/PathPrefix/configuration.yml +++ b/internal/suites/PathPrefix/configuration.yml @@ -21,10 +21,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080/auth/' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/Postgres/configuration.yml b/internal/suites/Postgres/configuration.yml index 958bf763a..ebeba850a 100644 --- a/internal/suites/Postgres/configuration.yml +++ b/internal/suites/Postgres/configuration.yml @@ -21,10 +21,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/ShortTimeouts/configuration.yml b/internal/suites/ShortTimeouts/configuration.yml index e73648a65..82bcb9149 100644 --- a/internal/suites/ShortTimeouts/configuration.yml +++ b/internal/suites/ShortTimeouts/configuration.yml @@ -22,8 +22,9 @@ authentication_backend: session: secret: unsecure_session_secret cookies: - - name: authelia_session - domain: example.com + - name: 'authelia_sessin' + domain: 'example.com' + authelia_url: 'https://login.example.com:8080' inactivity: 5 expiration: 8 remember_me: 1y diff --git a/internal/suites/Standalone/configuration.yml b/internal/suites/Standalone/configuration.yml index a86b20807..0deb53017 100644 --- a/internal/suites/Standalone/configuration.yml +++ b/internal/suites/Standalone/configuration.yml @@ -24,10 +24,12 @@ authentication_backend: path: /config/users.yml session: - domain: example.com expiration: 3600 inactivity: 300 remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/Traefik/configuration.yml b/internal/suites/Traefik/configuration.yml index 4ce6a5b4d..babc67281 100644 --- a/internal/suites/Traefik/configuration.yml +++ b/internal/suites/Traefik/configuration.yml @@ -21,10 +21,12 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' storage: encryption_key: a_not_so_secure_encryption_key diff --git a/internal/suites/Traefik2/configuration.yml b/internal/suites/Traefik2/configuration.yml index 8442be3cb..93da678a0 100644 --- a/internal/suites/Traefik2/configuration.yml +++ b/internal/suites/Traefik2/configuration.yml @@ -11,6 +11,11 @@ server: tls: certificate: /config/ssl/cert.pem key: /config/ssl/key.pem + endpoints: + authz: + forward-auth: + implementation: ForwardAuth + authn_strategies: [] log: level: debug @@ -21,10 +26,13 @@ authentication_backend: session: secret: unsecure_session_secret - domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' + redis: host: redis port: 6379 diff --git a/internal/suites/const.go b/internal/suites/const.go index 45cd89a00..5cd016775 100644 --- a/internal/suites/const.go +++ b/internal/suites/const.go @@ -14,8 +14,10 @@ var ( Example3DotCom = "example3.com:8080" ) -// PathPrefix the prefix/url_base of the login portal. -var PathPrefix = os.Getenv("PathPrefix") +// GetPathPrefix returns the prefix/url_base of the login portal. +func GetPathPrefix() string { + return os.Getenv("PathPrefix") +} // LoginBaseURLFmt the base URL of the login portal for specified baseDomain. func LoginBaseURLFmt(baseDomain string) string { @@ -81,6 +83,8 @@ const ( ) const ( + envFileProd = "./web/.env.production" + envFileDev = "./web/.env.development" namespaceAuthelia = "authelia" namespaceDashboard = "kubernetes-dashboard" namespaceKube = "kube-system" diff --git a/internal/suites/example/compose/authelia/docker-compose.backend.dev.yml b/internal/suites/example/compose/authelia/docker-compose.backend.dev.yml index 70b015131..a685a8c4b 100644 --- a/internal/suites/example/compose/authelia/docker-compose.backend.dev.yml +++ b/internal/suites/example/compose/authelia/docker-compose.backend.dev.yml @@ -21,11 +21,11 @@ services: - '${GOPATH}:/go' labels: # Traefik 1.x - - 'traefik.frontend.rule=Host:login.example.com;PathPrefix:/api,/locales' + - 'traefik.frontend.rule=Host:login.example.com;PathPrefix:/api,/devworkflow,/locales' - 'traefik.protocol=https' # Traefik 2.x - 'traefik.enable=true' - - 'traefik.http.routers.authelia_backend.rule=Host(`login.example.com`) && PathPrefix(`/.well-known`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/.well-known`) || Host(`login.example.com`) && PathPrefix(`/api`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/api`) || Host(`login.example.com`) && PathPrefix(`/locales`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/locales`) || Host(`login.example.com`) && Path(`/jwks.json`) || Host(`login.example.com`) && Path(`${PathPrefix}/jwks.json`)' # yamllint disable-line rule:line-length + - 'traefik.http.routers.authelia_backend.rule=Host(`login.example.com`) && PathPrefix(`/.well-known`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/.well-known`) || Host(`login.example.com`) && PathPrefix(`/api`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/api`) || Host(`login.example.com`) && PathPrefix(`/devworkflow`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/devworkflow`) || Host(`login.example.com`) && PathPrefix(`/locales`) || Host(`login.example.com`) && PathPrefix(`${PathPrefix}/locales`) || Host(`login.example.com`) && Path(`/jwks.json`) || Host(`login.example.com`) && Path(`${PathPrefix}/jwks.json`)' # yamllint disable-line rule:line-length - 'traefik.http.routers.authelia_backend.entrypoints=https' - 'traefik.http.routers.authelia_backend.tls=true' - 'traefik.http.services.authelia_backend.loadbalancer.server.scheme=https' diff --git a/internal/suites/example/compose/caddy/Caddyfile b/internal/suites/example/compose/caddy/Caddyfile index c0fc6d90e..7096787d6 100644 --- a/internal/suites/example/compose/caddy/Caddyfile +++ b/internal/suites/example/compose/caddy/Caddyfile @@ -29,6 +29,10 @@ login.example.com:8080 { import tls-transport } + reverse_proxy /devworkflow authelia-backend:9091 { + import tls-transport + } + reverse_proxy /jwks.json authelia-backend:9091 { import tls-transport } @@ -55,7 +59,7 @@ mail.example.com:8080 { tls internal log forward_auth authelia-backend:9091 { - uri /api/verify?rd=https://login.example.com:8080 + uri /api/authz/caddy copy_headers Remote-User Remote-Groups Remote-Name Remote-Email import tls-transport } diff --git a/internal/suites/example/compose/envoy/envoy.yaml b/internal/suites/example/compose/envoy/envoy.yaml index fc5039ebe..f43d28649 100644 --- a/internal/suites/example/compose/envoy/envoy.yaml +++ b/internal/suites/example/compose/envoy/envoy.yaml @@ -40,6 +40,10 @@ static_resources: prefix: "/locales/" route: cluster: authelia-backend + - match: + path: "/devworkflow" + route: + cluster: authelia-backend - match: path: "/jwks.json" route: @@ -75,7 +79,7 @@ static_resources: typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz http_service: - path_prefix: /api/verify/ + path_prefix: /api/authz/ext-authz/ server_uri: uri: authelia-backend:9091 cluster: authelia-backend @@ -87,18 +91,8 @@ static_resources: - exact: cookie - exact: proxy-authorization headers_to_add: - - key: X-Authelia-URL - value: 'https://login.example.com:8080/' - - key: X-Forwarded-Method - value: '%REQ(:METHOD)%' - key: X-Forwarded-Proto value: '%REQ(:SCHEME)%' - - key: X-Forwarded-Host - value: '%REQ(:AUTHORITY)%' - - key: X-Forwarded-URI - value: '%REQ(:PATH)%' - - key: X-Forwarded-For - value: '%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%' authorization_response: allowed_upstream_headers: patterns: diff --git a/internal/suites/example/compose/haproxy/Dockerfile b/internal/suites/example/compose/haproxy/Dockerfile index 0425a5186..a499fe142 100644 --- a/internal/suites/example/compose/haproxy/Dockerfile +++ b/internal/suites/example/compose/haproxy/Dockerfile @@ -1,4 +1,4 @@ -FROM haproxy:2.7.1-alpine +FROM haproxy:2.7.2-alpine USER root RUN \ diff --git a/internal/suites/example/compose/haproxy/haproxy.cfg b/internal/suites/example/compose/haproxy/haproxy.cfg index a73fbc48f..68e16148a 100644 --- a/internal/suites/example/compose/haproxy/haproxy.cfg +++ b/internal/suites/example/compose/haproxy/haproxy.cfg @@ -7,8 +7,10 @@ defaults default-server init-addr none mode http log global - option httplog option forwardfor + option httplog + option httpchk + http-check expect rstatus ^2 resolvers docker nameserver ip 127.0.0.11:53 @@ -25,7 +27,11 @@ frontend fe_http bind *:8080 ssl crt /usr/local/etc/haproxy/haproxy.pem acl api-path path_beg -i /api + acl devworkflow-path path -i -m end /devworkflow acl headers-path path -i -m end /headers + acl jwks-path path -i -m end /jwks.json + acl locales-path path_beg -i /locales + acl wellknown-path path_beg -i /.well-known acl host-authelia-portal hdr(host) -i login.example.com:8080 acl protected-frontends hdr(host) -m reg -i ^(?i)(admin|home|public|secure|singlefactor)\.example\.com @@ -41,31 +47,27 @@ frontend fe_http http-request set-var(req.method) str(PUT) if { method PUT } http-request set-var(req.method) str(PATCH) if { method PATCH } http-request set-var(req.method) str(DELETE) if { method DELETE } - http-request set-header X-Forwarded-Method %[var(req.method)] http-request set-header X-Real-IP %[src] - http-request set-header X-Forwarded-Proto %[var(req.scheme)] - http-request set-header X-Forwarded-Host %[req.hdr(Host)] - http-request set-header X-Forwarded-Uri %[path]%[var(req.questionmark)]%[query] + http-request set-header X-Original-Method %[var(req.method)] + http-request set-header X-Original-URL %[var(req.scheme)]://%[req.hdr(Host)]%[path]%[var(req.questionmark)]%[query] # be_auth_request is used to make HAProxy do the TLS termination since the Lua script # does not know how to handle it (see https://github.com/TimWolla/haproxy-auth-request/issues/12). - http-request lua.auth-request be_auth_request /api/verify if protected-frontends + http-request lua.auth-request be_auth_request /api/authz/auth-request if protected-frontends - http-request redirect location https://login.example.com:8080/?rd=%[var(req.scheme)]://%[base]%[var(req.questionmark)]%[query] if protected-frontends !{ var(txn.auth_response_successful) -m bool } + http-request redirect location https://login.example.com:8080/?rd=%[var(req.scheme)]://%[base]%[var(req.questionmark)]%[query]&rm=%[var(req.method)] if protected-frontends !{ var(txn.auth_response_successful) -m bool } - use_backend be_authelia if host-authelia-portal api-path + use_backend be_authelia if host-authelia-portal api-path || devworkflow-path || jwks-path || locales-path || wellknown-path use_backend fe_authelia if host-authelia-portal !api-path use_backend be_httpbin if protected-frontends headers-path use_backend be_mail if { hdr(host) -i mail.example.com:8080 } use_backend be_protected if protected-frontends backend be_auth_request - mode http server proxy 127.0.0.1:8085 listen be_auth_request_proxy - mode http bind 127.0.0.1:8085 server authelia-backend authelia-backend:9091 resolvers docker ssl verify none @@ -73,6 +75,9 @@ backend be_authelia server authelia-backend authelia-backend:9091 resolvers docker ssl verify none backend fe_authelia + option httpchk + http-check expect rstatus ^2 + server authelia-frontend authelia-frontend:3000 check resolvers docker server authelia-backend authelia-backend:9091 check backup resolvers docker ssl verify none diff --git a/internal/suites/example/compose/httpbin/docker-compose.yml b/internal/suites/example/compose/httpbin/docker-compose.yml index fae6542b7..15683d340 100644 --- a/internal/suites/example/compose/httpbin/docker-compose.yml +++ b/internal/suites/example/compose/httpbin/docker-compose.yml @@ -9,10 +9,10 @@ services: # Traefik 1.x - 'traefik.frontend.rule=Host:public.example.com;Path:/headers' - 'traefik.frontend.priority=120' - - 'traefik.frontend.auth.forward.address=https://authelia-backend:9091/api/verify?rd=https://login.example.com:8080/' + - 'traefik.frontend.auth.forward.address=https://authelia-backend:9091/api/authz/forward-auth' - 'traefik.frontend.auth.forward.tls.insecureSkipVerify=true' - 'traefik.frontend.auth.forward.trustForwardHeader=true' - - 'traefik.frontend.auth.forward.authResponseHeaders=Remote-User, Remote-Groups, Remote-Name, Remote-Email' + - 'traefik.frontend.auth.forward.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' # Traefik 2.x - 'traefik.enable=true' - 'traefik.http.routers.httpbin.rule=Host(`public.example.com`) && Path(`/headers`)' diff --git a/internal/suites/example/compose/nginx/backend/docker-compose.yml b/internal/suites/example/compose/nginx/backend/docker-compose.yml index 89ed3adbe..e71ed8dbd 100644 --- a/internal/suites/example/compose/nginx/backend/docker-compose.yml +++ b/internal/suites/example/compose/nginx/backend/docker-compose.yml @@ -6,7 +6,7 @@ services: labels: # Traefik 1.x - 'traefik.frontend.rule=Host:home.example.com,public.example.com,secure.example.com,admin.example.com,singlefactor.example.com' # yamllint disable-line rule:line-length - - 'traefik.frontend.auth.forward.address=https://authelia-backend:9091/api/verify?rd=https://login.example.com:8080' # yamllint disable-line rule:line-length + - 'traefik.frontend.auth.forward.address=https://authelia-backend:9091/api/authz/forward-auth' # yamllint disable-line rule:line-length - 'traefik.frontend.auth.forward.tls.insecureSkipVerify=true' - 'traefik.frontend.auth.forward.trustForwardHeader=true' - 'traefik.frontend.auth.forward.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email' diff --git a/internal/suites/example/compose/nginx/portal/nginx.conf b/internal/suites/example/compose/nginx/portal/nginx.conf index f44c373a2..0af06dfa4 100644 --- a/internal/suites/example/compose/nginx/portal/nginx.conf +++ b/internal/suites/example/compose/nginx/portal/nginx.conf @@ -148,7 +148,7 @@ http { server_name ~^(public|admin|secure|dev|singlefactor|mx[1-2])(\.mail)?\.(?example([0-9])*\.com)$; resolver 127.0.0.11 ipv6=off; - set $upstream_verify https://authelia-backend:9091/api/verify; + set $upstream_verify https://authelia-backend:9091/api/authz/auth-request; set $upstream_endpoint http://nginx-backend; set $upstream_headers http://httpbin:8000/headers; @@ -209,12 +209,9 @@ http { # # X-Forwarded-Proto is mandatory since Authelia uses the "trust proxy" option. # See https://expressjs.com/en/guide/behind-proxies.html + proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$http_host$request_uri; - proxy_set_header X-Forwarded-Method $request_method; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-URI $request_uri; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Authelia can receive Proxy-Authorization to authenticate however most of the clients @@ -256,7 +253,7 @@ http { server_name ~^oidc(-public)?\.(?example([0-9])*\.com)$; resolver 127.0.0.11 ipv6=off; - set $upstream_verify https://authelia-backend:9091/api/verify; + set $upstream_verify https://authelia-backend:9091/api/authz/auth-request; set $upstream_endpoint http://oidc-client:8080; ssl_certificate /etc/ssl/server.cert; @@ -298,12 +295,9 @@ http { # # X-Forwarded-Proto is mandatory since Authelia uses the "trust proxy" option. # See https://expressjs.com/en/guide/behind-proxies.html + proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$http_host$request_uri; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-URI $request_uri; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Authelia can receive Proxy-Authorization to authenticate however most of the clients diff --git a/internal/suites/example/compose/traefik2/docker-compose.yml b/internal/suites/example/compose/traefik2/docker-compose.yml index 3c61ce86a..ea30cf683 100644 --- a/internal/suites/example/compose/traefik2/docker-compose.yml +++ b/internal/suites/example/compose/traefik2/docker-compose.yml @@ -12,7 +12,7 @@ services: - 'traefik.http.routers.api.service=api@internal' - 'traefik.http.routers.api.tls=true' # Traefik 2.x - - 'traefik.http.middlewares.authelia.forwardauth.address=https://authelia-backend:9091${PathPrefix}/api/verify?rd=https://login.example.com:8080${PathPrefix}' # yamllint disable-line rule:line-length + - 'traefik.http.middlewares.authelia.forwardauth.address=https://authelia-backend:9091${PathPrefix}/api/authz/forward-auth' # yamllint disable-line rule:line-length - 'traefik.http.middlewares.authelia.forwardauth.tls.insecureSkipVerify=true' - 'traefik.http.middlewares.authelia.forwardauth.trustForwardHeader=true' - 'traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User, Remote-Groups, Remote-Name, Remote-Email' # yamllint disable-line rule:line-length diff --git a/internal/suites/example/kube/authelia/authelia.yml b/internal/suites/example/kube/authelia/authelia.yml index da1366902..90e138189 100644 --- a/internal/suites/example/kube/authelia/authelia.yml +++ b/internal/suites/example/kube/authelia/authelia.yml @@ -142,12 +142,15 @@ metadata: app.kubernetes.io/name: authelia spec: forwardAuth: - address: https://authelia-service.authelia.svc.cluster.local/api/verify?rd=https://login.example.com:8080 + address: 'https://authelia-service.authelia.svc.cluster.local/api/authz/forward-auth' + trustForwardHeader: true authResponseHeaders: - - Remote-User - - Remote-Name - - Remote-Email - - Remote-Groups + - 'Authorization' + - 'Proxy-Authorization' + - 'Remote-User' + - 'Remote-Groups' + - 'Remote-Email' + - 'Remote-Name' tls: insecureSkipVerify: true ... diff --git a/internal/suites/example/kube/authelia/configs/configuration.yml b/internal/suites/example/kube/authelia/configs/configuration.yml index fa4e3a7e5..19eec843f 100644 --- a/internal/suites/example/kube/authelia/configs/configuration.yml +++ b/internal/suites/example/kube/authelia/configs/configuration.yml @@ -87,7 +87,10 @@ session: expiration: 3600 # 1 hour inactivity: 300 # 5 minutes remember_me: 1y - domain: example.com + cookies: + - domain: 'example.com' + authelia_url: 'https://login.example.com:8080' + redis: host: redis-service port: 6379 diff --git a/internal/suites/scenario_available_methods_test.go b/internal/suites/scenario_available_methods_test.go index 69bba6434..708ab5559 100644 --- a/internal/suites/scenario_available_methods_test.go +++ b/internal/suites/scenario_available_methods_test.go @@ -16,7 +16,7 @@ type AvailableMethodsScenario struct { func NewAvailableMethodsScenario(methods []string) *AvailableMethodsScenario { return &AvailableMethodsScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), methods: methods, } } diff --git a/internal/suites/scenario_bypass_policy_test.go b/internal/suites/scenario_bypass_policy_test.go index 497b837e6..1c3273258 100644 --- a/internal/suites/scenario_bypass_policy_test.go +++ b/internal/suites/scenario_bypass_policy_test.go @@ -16,7 +16,7 @@ type BypassPolicyScenario struct { func NewBypassPolicyScenario() *BypassPolicyScenario { return &BypassPolicyScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/scenario_custom_headers_test.go b/internal/suites/scenario_custom_headers_test.go index 38e0c7168..355113c55 100644 --- a/internal/suites/scenario_custom_headers_test.go +++ b/internal/suites/scenario_custom_headers_test.go @@ -19,7 +19,7 @@ type CustomHeadersScenario struct { func NewCustomHeadersScenario() *CustomHeadersScenario { return &CustomHeadersScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/scenario_default_redirection_url_test.go b/internal/suites/scenario_default_redirection_url_test.go index 31fca33d0..b6adeee9c 100644 --- a/internal/suites/scenario_default_redirection_url_test.go +++ b/internal/suites/scenario_default_redirection_url_test.go @@ -18,7 +18,7 @@ type DefaultRedirectionURLScenario struct { func NewDefaultRedirectionURLScenario() *DefaultRedirectionURLScenario { return &DefaultRedirectionURLScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/scenario_inactivity_test.go b/internal/suites/scenario_inactivity_test.go index 5f6a4bdfe..0af42ed79 100644 --- a/internal/suites/scenario_inactivity_test.go +++ b/internal/suites/scenario_inactivity_test.go @@ -18,7 +18,7 @@ type InactivityScenario struct { func NewInactivityScenario() *InactivityScenario { return &InactivityScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/scenario_multiple_cookie_domain_test.go b/internal/suites/scenario_multiple_cookie_domain_test.go index 15016ceae..e8acb3c5e 100644 --- a/internal/suites/scenario_multiple_cookie_domain_test.go +++ b/internal/suites/scenario_multiple_cookie_domain_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "strings" "time" ) @@ -12,17 +13,19 @@ type MultiCookieDomainScenario struct { *RodSuite domain, nextDomain string + cookieNames []string remember bool } // NewMultiCookieDomainScenario returns a new Multi Cookie Domain Test Scenario. -func NewMultiCookieDomainScenario(domain, nextDomain string, remember bool) *MultiCookieDomainScenario { +func NewMultiCookieDomainScenario(domain, nextDomain string, cookieNames []string, remember bool) *MultiCookieDomainScenario { return &MultiCookieDomainScenario{ - RodSuite: new(RodSuite), - domain: domain, - nextDomain: nextDomain, - remember: remember, + RodSuite: NewRodSuite(""), + domain: domain, + nextDomain: nextDomain, + cookieNames: cookieNames, + remember: remember, } } @@ -56,6 +59,22 @@ func (s *MultiCookieDomainScenario) TearDownTest() { s.MustClose() } +func (s *MultiCookieDomainScenario) TestCookieName() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer func() { + cancel() + s.collectScreenshot(ctx.Err(), s.Page) + }() + + s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", s.remember, s.domain, "") + + s.WaitElementLocatedByID(s.T(), s.Context(ctx), "logout-button") + + cookieNames := s.GetCookieNames() + + s.Assert().Equalf(s.cookieNames, cookieNames, "cookie names should include '%s' (only and all of) but includes '%s'", strings.Join(s.cookieNames, ","), strings.Join(cookieNames, ",")) +} + func (s *MultiCookieDomainScenario) TestRememberMe() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer func() { diff --git a/internal/suites/scenario_oidc_test.go b/internal/suites/scenario_oidc_test.go index a79d9bf59..d145c190d 100644 --- a/internal/suites/scenario_oidc_test.go +++ b/internal/suites/scenario_oidc_test.go @@ -22,7 +22,7 @@ type OIDCScenario struct { func NewOIDCScenario() *OIDCScenario { return &OIDCScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/scenario_one_factor_test.go b/internal/suites/scenario_one_factor_test.go index 6fd6a5523..519fc3a58 100644 --- a/internal/suites/scenario_one_factor_test.go +++ b/internal/suites/scenario_one_factor_test.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log" + "net/url" + "regexp" "testing" "time" @@ -16,7 +18,7 @@ type OneFactorSuite struct { func New1FAScenario() *OneFactorSuite { return &OneFactorSuite{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } @@ -48,8 +50,38 @@ func (s *OneFactorSuite) TearDownTest() { s.MustClose() } +func (s *OneFactorSuite) TestShouldNotAuthorizeSecretBeforeOneFactor() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer func() { + cancel() + s.collectScreenshot(ctx.Err(), s.Page) + }() + + targetURL := fmt.Sprintf("%s/secret.html", SingleFactorBaseURL) + + s.doVisit(s.T(), s.Context(ctx), targetURL) + + s.verifyIsFirstFactorPage(s.T(), s.Context(ctx)) + + raw := GetLoginBaseURLWithFallbackPrefix(BaseDomain, "/") + + expected, err := url.ParseRequestURI(raw) + s.Assert().NoError(err) + s.Require().NotNil(expected) + + query := expected.Query() + + query.Set("rd", targetURL) + + expected.RawQuery = query.Encode() + + rx := regexp.MustCompile(fmt.Sprintf(`^%s(&rm=GET)?$`, regexp.QuoteMeta(expected.String()))) + + s.verifyURLIsRegexp(s.T(), s.Context(ctx), rx) +} + func (s *OneFactorSuite) TestShouldAuthorizeSecretAfterOneFactor() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer func() { cancel() s.collectScreenshot(ctx.Err(), s.Page) diff --git a/internal/suites/scenario_password_complexity_test.go b/internal/suites/scenario_password_complexity_test.go index d3a0a3b24..5c894986a 100644 --- a/internal/suites/scenario_password_complexity_test.go +++ b/internal/suites/scenario_password_complexity_test.go @@ -14,7 +14,7 @@ type PasswordComplexityScenario struct { } func NewPasswordComplexityScenario() *PasswordComplexityScenario { - return &PasswordComplexityScenario{RodSuite: new(RodSuite)} + return &PasswordComplexityScenario{RodSuite: NewRodSuite("")} } func (s *PasswordComplexityScenario) SetupSuite() { diff --git a/internal/suites/scenario_redirection_check_test.go b/internal/suites/scenario_redirection_check_test.go index fd47004b4..a75465822 100644 --- a/internal/suites/scenario_redirection_check_test.go +++ b/internal/suites/scenario_redirection_check_test.go @@ -15,7 +15,7 @@ type RedirectionCheckScenario struct { func NewRedirectionCheckScenario() *RedirectionCheckScenario { return &RedirectionCheckScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/scenario_redirection_url_test.go b/internal/suites/scenario_redirection_url_test.go index e101287aa..dd67d5b7c 100644 --- a/internal/suites/scenario_redirection_url_test.go +++ b/internal/suites/scenario_redirection_url_test.go @@ -16,7 +16,7 @@ type RedirectionURLScenario struct { func NewRedirectionURLScenario() *RedirectionURLScenario { return &RedirectionURLScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/scenario_regulation_test.go b/internal/suites/scenario_regulation_test.go index 336d6b814..4f8df635e 100644 --- a/internal/suites/scenario_regulation_test.go +++ b/internal/suites/scenario_regulation_test.go @@ -16,7 +16,7 @@ type RegulationScenario struct { func NewRegulationScenario() *RegulationScenario { return &RegulationScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/scenario_reset_password_test.go b/internal/suites/scenario_reset_password_test.go index e5e93a3f6..7c3e0da6b 100644 --- a/internal/suites/scenario_reset_password_test.go +++ b/internal/suites/scenario_reset_password_test.go @@ -14,7 +14,7 @@ type ResetPasswordScenario struct { } func NewResetPasswordScenario() *ResetPasswordScenario { - return &ResetPasswordScenario{RodSuite: new(RodSuite)} + return &ResetPasswordScenario{RodSuite: NewRodSuite("")} } func (s *ResetPasswordScenario) SetupSuite() { diff --git a/internal/suites/scenario_signin_email_test.go b/internal/suites/scenario_signin_email_test.go index 686c8ab3c..9c8b6501a 100644 --- a/internal/suites/scenario_signin_email_test.go +++ b/internal/suites/scenario_signin_email_test.go @@ -18,7 +18,7 @@ type SigninEmailScenario struct { func NewSigninEmailScenario() *SigninEmailScenario { return &SigninEmailScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/scenario_two_factor_test.go b/internal/suites/scenario_two_factor_test.go index 3c10cb100..de4e077cd 100644 --- a/internal/suites/scenario_two_factor_test.go +++ b/internal/suites/scenario_two_factor_test.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log" + "net/url" + "regexp" "testing" "time" @@ -18,7 +20,7 @@ type TwoFactorSuite struct { func New2FAScenario() *TwoFactorSuite { return &TwoFactorSuite{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } @@ -62,6 +64,36 @@ func (s *TwoFactorSuite) TearDownTest() { s.MustClose() } +func (s *TwoFactorSuite) TestShouldNotAuthorizeSecretBeforeTwoFactor() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer func() { + cancel() + s.collectScreenshot(ctx.Err(), s.Page) + }() + + targetURL := fmt.Sprintf("%s/secret.html", AdminBaseURL) + + s.doVisit(s.T(), s.Context(ctx), targetURL) + + s.verifyIsFirstFactorPage(s.T(), s.Context(ctx)) + + raw := GetLoginBaseURLWithFallbackPrefix(BaseDomain, "/") + + expected, err := url.ParseRequestURI(raw) + s.Assert().NoError(err) + s.Require().NotNil(expected) + + query := expected.Query() + + query.Set("rd", targetURL) + + expected.RawQuery = query.Encode() + + rx := regexp.MustCompile(fmt.Sprintf(`^%s(&rm=GET)?$`, regexp.QuoteMeta(expected.String()))) + + s.verifyURLIsRegexp(s.T(), s.Context(ctx), rx) +} + func (s *TwoFactorSuite) TestShouldAuthorizeSecretAfterTwoFactor() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer func() { diff --git a/internal/suites/scenario_user_preferences_test.go b/internal/suites/scenario_user_preferences_test.go index 75db5cd53..133d09ec8 100644 --- a/internal/suites/scenario_user_preferences_test.go +++ b/internal/suites/scenario_user_preferences_test.go @@ -15,7 +15,7 @@ type UserPreferencesScenario struct { func NewUserPreferencesScenario() *UserPreferencesScenario { return &UserPreferencesScenario{ - RodSuite: new(RodSuite), + RodSuite: NewRodSuite(""), } } diff --git a/internal/suites/suite_activedirectory_test.go b/internal/suites/suite_activedirectory_test.go index 9e7bce985..4abb258c4 100644 --- a/internal/suites/suite_activedirectory_test.go +++ b/internal/suites/suite_activedirectory_test.go @@ -11,7 +11,9 @@ type ActiveDirectorySuite struct { } func NewActiveDirectorySuite() *ActiveDirectorySuite { - return &ActiveDirectorySuite{RodSuite: new(RodSuite)} + return &ActiveDirectorySuite{ + RodSuite: NewRodSuite(activedirectorySuiteName), + } } func (s *ActiveDirectorySuite) Test1FAScenario() { diff --git a/internal/suites/suite_bypass_all_test.go b/internal/suites/suite_bypass_all_test.go index 4450d6206..6eaaccba8 100644 --- a/internal/suites/suite_bypass_all_test.go +++ b/internal/suites/suite_bypass_all_test.go @@ -15,10 +15,14 @@ type BypassAllWebDriverSuite struct { } func NewBypassAllWebDriverSuite() *BypassAllWebDriverSuite { - return &BypassAllWebDriverSuite{RodSuite: new(RodSuite)} + return &BypassAllWebDriverSuite{ + RodSuite: NewRodSuite(""), + } } func (s *BypassAllWebDriverSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + browser, err := StartRod() if err != nil { @@ -61,11 +65,15 @@ func (s *BypassAllWebDriverSuite) TestShouldAccessPublicResource() { } type BypassAllSuite struct { - suite.Suite + *BaseSuite } func NewBypassAllSuite() *BypassAllSuite { - return &BypassAllSuite{} + return &BypassAllSuite{ + BaseSuite: &BaseSuite{ + Name: bypassAllSuiteName, + }, + } } func (s *BypassAllSuite) TestBypassAllWebDriverSuite() { diff --git a/internal/suites/suite_caddy_test.go b/internal/suites/suite_caddy_test.go index a366dd220..6df9d0ffd 100644 --- a/internal/suites/suite_caddy_test.go +++ b/internal/suites/suite_caddy_test.go @@ -11,7 +11,9 @@ type CaddySuite struct { } func NewCaddySuite() *CaddySuite { - return &CaddySuite{RodSuite: new(RodSuite)} + return &CaddySuite{ + RodSuite: NewRodSuite(caddySuiteName), + } } func (s *CaddySuite) Test1FAScenario() { diff --git a/internal/suites/suite_cli_test.go b/internal/suites/suite_cli_test.go index 957609804..cb7229f63 100644 --- a/internal/suites/suite_cli_test.go +++ b/internal/suites/suite_cli_test.go @@ -23,10 +23,18 @@ type CLISuite struct { } func NewCLISuite() *CLISuite { - return &CLISuite{CommandSuite: new(CommandSuite)} + return &CLISuite{ + CommandSuite: &CommandSuite{ + BaseSuite: &BaseSuite{ + Name: cliSuiteName, + }, + }, + } } func (s *CLISuite) SetupSuite() { + s.BaseSuite.SetupSuite() + dockerEnvironment := NewDockerEnvironment([]string{ "internal/suites/docker-compose.yml", "internal/suites/CLI/docker-compose.yml", diff --git a/internal/suites/suite_docker_test.go b/internal/suites/suite_docker_test.go index 68df0c6ca..6f6e1c982 100644 --- a/internal/suites/suite_docker_test.go +++ b/internal/suites/suite_docker_test.go @@ -11,7 +11,9 @@ type DockerSuite struct { } func NewDockerSuite() *DockerSuite { - return &DockerSuite{RodSuite: new(RodSuite)} + return &DockerSuite{ + RodSuite: NewRodSuite(dockerSuiteName), + } } func (s *DockerSuite) Test1FAScenario() { diff --git a/internal/suites/suite_duo_push_test.go b/internal/suites/suite_duo_push_test.go index 2958388aa..705a102a7 100644 --- a/internal/suites/suite_duo_push_test.go +++ b/internal/suites/suite_duo_push_test.go @@ -20,7 +20,9 @@ type DuoPushWebDriverSuite struct { } func NewDuoPushWebDriverSuite() *DuoPushWebDriverSuite { - return &DuoPushWebDriverSuite{RodSuite: new(RodSuite)} + return &DuoPushWebDriverSuite{ + RodSuite: NewRodSuite(""), + } } func (s *DuoPushWebDriverSuite) SetupSuite() { @@ -386,10 +388,12 @@ type DuoPushDefaultRedirectionSuite struct { } func NewDuoPushDefaultRedirectionSuite() *DuoPushDefaultRedirectionSuite { - return &DuoPushDefaultRedirectionSuite{RodSuite: new(RodSuite)} + return &DuoPushDefaultRedirectionSuite{RodSuite: NewRodSuite(duoPushSuiteName)} } func (s *DuoPushDefaultRedirectionSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + browser, err := StartRod() if err != nil { @@ -444,11 +448,15 @@ func (s *DuoPushDefaultRedirectionSuite) TestUserIsRedirectedToDefaultURL() { } type DuoPushSuite struct { - suite.Suite + *BaseSuite } func NewDuoPushSuite() *DuoPushSuite { - return &DuoPushSuite{} + return &DuoPushSuite{ + BaseSuite: &BaseSuite{ + Name: duoPushSuiteName, + }, + } } func (s *DuoPushSuite) TestDuoPushWebDriverSuite() { diff --git a/internal/suites/suite_envoy_test.go b/internal/suites/suite_envoy_test.go index ca7cb1149..040a47b9a 100644 --- a/internal/suites/suite_envoy_test.go +++ b/internal/suites/suite_envoy_test.go @@ -11,7 +11,9 @@ type EnvoySuite struct { } func NewEnvoySuite() *EnvoySuite { - return &EnvoySuite{RodSuite: new(RodSuite)} + return &EnvoySuite{ + RodSuite: NewRodSuite(envoySuiteName), + } } func (s *EnvoySuite) Test1FAScenario() { diff --git a/internal/suites/suite_haproxy_test.go b/internal/suites/suite_haproxy_test.go index 96ef115f9..2c935ea7e 100644 --- a/internal/suites/suite_haproxy_test.go +++ b/internal/suites/suite_haproxy_test.go @@ -11,7 +11,9 @@ type HAProxySuite struct { } func NewHAProxySuite() *HAProxySuite { - return &HAProxySuite{RodSuite: new(RodSuite)} + return &HAProxySuite{ + RodSuite: NewRodSuite(haproxySuiteName), + } } func (s *HAProxySuite) Test1FAScenario() { diff --git a/internal/suites/suite_high_availability_test.go b/internal/suites/suite_high_availability_test.go index bbe1b0a7d..d8077d28e 100644 --- a/internal/suites/suite_high_availability_test.go +++ b/internal/suites/suite_high_availability_test.go @@ -17,10 +17,14 @@ type HighAvailabilityWebDriverSuite struct { } func NewHighAvailabilityWebDriverSuite() *HighAvailabilityWebDriverSuite { - return &HighAvailabilityWebDriverSuite{RodSuite: new(RodSuite)} + return &HighAvailabilityWebDriverSuite{ + RodSuite: NewRodSuite(""), + } } func (s *HighAvailabilityWebDriverSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + browser, err := StartRod() if err != nil { @@ -183,7 +187,9 @@ func (s *HighAvailabilityWebDriverSuite) TestShouldKeepSessionAfterAutheliaResta } var UserJohn = "john" + var UserBob = "bob" + var UserHarry = "harry" var Users = []string{UserJohn, UserBob, UserHarry} @@ -263,11 +269,15 @@ func (s *HighAvailabilityWebDriverSuite) TestShouldVerifyAccessControl() { } type HighAvailabilitySuite struct { - suite.Suite + *BaseSuite } func NewHighAvailabilitySuite() *HighAvailabilitySuite { - return &HighAvailabilitySuite{} + return &HighAvailabilitySuite{ + BaseSuite: &BaseSuite{ + Name: highAvailabilitySuiteName, + }, + } } func DoGetWithAuth(t *testing.T, username, password string) int { diff --git a/internal/suites/suite_kubernetes_test.go b/internal/suites/suite_kubernetes_test.go index fe382ee10..c766301fa 100644 --- a/internal/suites/suite_kubernetes_test.go +++ b/internal/suites/suite_kubernetes_test.go @@ -11,7 +11,9 @@ type KubernetesSuite struct { } func NewKubernetesSuite() *KubernetesSuite { - return &KubernetesSuite{RodSuite: new(RodSuite)} + return &KubernetesSuite{ + RodSuite: NewRodSuite(kubernetesSuiteName), + } } func (s *KubernetesSuite) Test1FAScenario() { diff --git a/internal/suites/suite_ldap_test.go b/internal/suites/suite_ldap_test.go index c75627806..382354d3a 100644 --- a/internal/suites/suite_ldap_test.go +++ b/internal/suites/suite_ldap_test.go @@ -11,7 +11,9 @@ type LDAPSuite struct { } func NewLDAPSuite() *LDAPSuite { - return &LDAPSuite{RodSuite: new(RodSuite)} + return &LDAPSuite{ + RodSuite: NewRodSuite(ldapSuiteName), + } } func (s *LDAPSuite) Test1FAScenario() { diff --git a/internal/suites/suite_mariadb_test.go b/internal/suites/suite_mariadb_test.go index 35b71f23b..694c1a2f3 100644 --- a/internal/suites/suite_mariadb_test.go +++ b/internal/suites/suite_mariadb_test.go @@ -11,7 +11,9 @@ type MariaDBSuite struct { } func NewMariaDBSuite() *MariaDBSuite { - return &MariaDBSuite{RodSuite: new(RodSuite)} + return &MariaDBSuite{ + RodSuite: NewRodSuite(mariadbSuiteName), + } } func (s *MariaDBSuite) Test1FAScenario() { diff --git a/internal/suites/suite_multi_cookie_domain_test.go b/internal/suites/suite_multi_cookie_domain_test.go index ff7281c34..33861fd6a 100644 --- a/internal/suites/suite_multi_cookie_domain_test.go +++ b/internal/suites/suite_multi_cookie_domain_test.go @@ -7,23 +7,27 @@ import ( ) func NewMultiCookieDomainSuite() *MultiCookieDomainSuite { - return &MultiCookieDomainSuite{} + return &MultiCookieDomainSuite{ + BaseSuite: &BaseSuite{ + Name: multiCookieDomainSuiteName, + }, + } } type MultiCookieDomainSuite struct { - suite.Suite + *BaseSuite } func (s *MultiCookieDomainSuite) TestMultiCookieDomainFirstDomainScenario() { - suite.Run(s.T(), NewMultiCookieDomainScenario(BaseDomain, Example2DotCom, true)) + suite.Run(s.T(), NewMultiCookieDomainScenario(BaseDomain, Example2DotCom, []string{"authelia_session"}, true)) } func (s *MultiCookieDomainSuite) TestMultiCookieDomainSecondDomainScenario() { - suite.Run(s.T(), NewMultiCookieDomainScenario(Example2DotCom, BaseDomain, false)) + suite.Run(s.T(), NewMultiCookieDomainScenario(Example2DotCom, BaseDomain, []string{"example2_session"}, false)) } func (s *MultiCookieDomainSuite) TestMultiCookieDomainThirdDomainScenario() { - suite.Run(s.T(), NewMultiCookieDomainScenario(Example3DotCom, BaseDomain, true)) + suite.Run(s.T(), NewMultiCookieDomainScenario(Example3DotCom, BaseDomain, []string{"authelia_session"}, true)) } func TestMultiCookieDomainSuite(t *testing.T) { diff --git a/internal/suites/suite_mysql_test.go b/internal/suites/suite_mysql_test.go index a2e90e01d..4a3618827 100644 --- a/internal/suites/suite_mysql_test.go +++ b/internal/suites/suite_mysql_test.go @@ -11,7 +11,9 @@ type MySQLSuite struct { } func NewMySQLSuite() *MySQLSuite { - return &MySQLSuite{RodSuite: new(RodSuite)} + return &MySQLSuite{ + RodSuite: NewRodSuite(mysqlSuiteName), + } } func (s *MySQLSuite) Test1FAScenario() { diff --git a/internal/suites/suite_network_acl_test.go b/internal/suites/suite_network_acl_test.go index 1b3063d6e..88178a288 100644 --- a/internal/suites/suite_network_acl_test.go +++ b/internal/suites/suite_network_acl_test.go @@ -10,11 +10,15 @@ import ( ) type NetworkACLSuite struct { - suite.Suite + *BaseSuite } func NewNetworkACLSuite() *NetworkACLSuite { - return &NetworkACLSuite{} + return &NetworkACLSuite{ + BaseSuite: &BaseSuite{ + Name: networkACLSuiteName, + }, + } } func (s *NetworkACLSuite) TestShouldAccessSecretUpon2FA() { diff --git a/internal/suites/suite_oidc_test.go b/internal/suites/suite_oidc_test.go index acf224dd8..a3320954e 100644 --- a/internal/suites/suite_oidc_test.go +++ b/internal/suites/suite_oidc_test.go @@ -11,7 +11,9 @@ type OIDCSuite struct { } func NewOIDCSuite() *OIDCSuite { - return &OIDCSuite{RodSuite: new(RodSuite)} + return &OIDCSuite{ + RodSuite: NewRodSuite(oidcSuiteName), + } } func (s *OIDCSuite) TestOIDCScenario() { diff --git a/internal/suites/suite_oidc_traefik_test.go b/internal/suites/suite_oidc_traefik_test.go index eae84c4d6..2e4a14bab 100644 --- a/internal/suites/suite_oidc_traefik_test.go +++ b/internal/suites/suite_oidc_traefik_test.go @@ -11,7 +11,9 @@ type OIDCTraefikSuite struct { } func NewOIDCTraefikSuite() *OIDCTraefikSuite { - return &OIDCTraefikSuite{RodSuite: new(RodSuite)} + return &OIDCTraefikSuite{ + RodSuite: NewRodSuite(oidcTraefikSuiteName), + } } func (s *OIDCTraefikSuite) TestOIDCScenario() { diff --git a/internal/suites/suite_one_factor_only_test.go b/internal/suites/suite_one_factor_only_test.go index f669bc360..688b73bd9 100644 --- a/internal/suites/suite_one_factor_only_test.go +++ b/internal/suites/suite_one_factor_only_test.go @@ -11,18 +11,18 @@ import ( ) type OneFactorOnlySuite struct { - suite.Suite -} - -type OneFactorOnlyWebSuite struct { *RodSuite } -func NewOneFactorOnlyWebSuite() *OneFactorOnlyWebSuite { - return &OneFactorOnlyWebSuite{RodSuite: new(RodSuite)} +func NewOneFactorOnlySuite() *OneFactorOnlySuite { + return &OneFactorOnlySuite{ + RodSuite: NewRodSuite(oneFactorOnlySuiteName), + } } -func (s *OneFactorOnlyWebSuite) SetupSuite() { +func (s *OneFactorOnlySuite) SetupSuite() { + s.BaseSuite.SetupSuite() + browser, err := StartRod() if err != nil { @@ -32,7 +32,7 @@ func (s *OneFactorOnlyWebSuite) SetupSuite() { s.RodSession = browser } -func (s *OneFactorOnlyWebSuite) TearDownSuite() { +func (s *OneFactorOnlySuite) TearDownSuite() { err := s.RodSession.Stop() if err != nil { @@ -40,18 +40,18 @@ func (s *OneFactorOnlyWebSuite) TearDownSuite() { } } -func (s *OneFactorOnlyWebSuite) SetupTest() { +func (s *OneFactorOnlySuite) SetupTest() { s.Page = s.doCreateTab(s.T(), HomeBaseURL) s.verifyIsHome(s.T(), s.Page) } -func (s *OneFactorOnlyWebSuite) TearDownTest() { +func (s *OneFactorOnlySuite) TearDownTest() { s.collectCoverage(s.Page) s.MustClose() } // No target url is provided, then the user should be redirect to the default url. -func (s *OneFactorOnlyWebSuite) TestShouldRedirectUserToDefaultURL() { +func (s *OneFactorOnlySuite) TestShouldRedirectUserToDefaultURL() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer func() { cancel() @@ -63,7 +63,7 @@ func (s *OneFactorOnlyWebSuite) TestShouldRedirectUserToDefaultURL() { } // Unsafe URL is provided, then the user should be redirect to the default url. -func (s *OneFactorOnlyWebSuite) TestShouldRedirectUserToDefaultURLWhenURLIsUnsafe() { +func (s *OneFactorOnlySuite) TestShouldRedirectUserToDefaultURLWhenURLIsUnsafe() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer func() { cancel() @@ -75,7 +75,7 @@ func (s *OneFactorOnlyWebSuite) TestShouldRedirectUserToDefaultURLWhenURLIsUnsaf } // When use logged in and visit the portal again, she gets redirect to the authenticated view. -func (s *OneFactorOnlyWebSuite) TestShouldDisplayAuthenticatedView() { +func (s *OneFactorOnlySuite) TestShouldDisplayAuthenticatedView() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer func() { cancel() @@ -88,7 +88,7 @@ func (s *OneFactorOnlyWebSuite) TestShouldDisplayAuthenticatedView() { s.verifyIsAuthenticatedPage(s.T(), s.Context(ctx)) } -func (s *OneFactorOnlyWebSuite) TestShouldRedirectAlreadyAuthenticatedUser() { +func (s *OneFactorOnlySuite) TestShouldRedirectAlreadyAuthenticatedUser() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer func() { cancel() @@ -103,7 +103,7 @@ func (s *OneFactorOnlyWebSuite) TestShouldRedirectAlreadyAuthenticatedUser() { s.verifyURLIs(s.T(), s.Context(ctx), "https://singlefactor.example.com:8080/secret.html") } -func (s *OneFactorOnlyWebSuite) TestShouldNotRedirectAlreadyAuthenticatedUserToUnsafeURL() { +func (s *OneFactorOnlySuite) TestShouldNotRedirectAlreadyAuthenticatedUserToUnsafeURL() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer func() { cancel() @@ -118,14 +118,10 @@ func (s *OneFactorOnlyWebSuite) TestShouldNotRedirectAlreadyAuthenticatedUserToU s.verifyNotificationDisplayed(s.T(), s.Context(ctx), "Redirection was determined to be unsafe and aborted. Ensure the redirection URL is correct.") } -func (s *OneFactorOnlySuite) TestWeb() { - suite.Run(s.T(), NewOneFactorOnlyWebSuite()) -} - func TestOneFactorOnlySuite(t *testing.T) { if testing.Short() { t.Skip("skipping suite test in short mode") } - suite.Run(t, new(OneFactorOnlySuite)) + suite.Run(t, NewOneFactorOnlySuite()) } diff --git a/internal/suites/suite_pathprefix_test.go b/internal/suites/suite_pathprefix_test.go index 1f310b149..a821ca17a 100644 --- a/internal/suites/suite_pathprefix_test.go +++ b/internal/suites/suite_pathprefix_test.go @@ -11,7 +11,13 @@ type PathPrefixSuite struct { } func NewPathPrefixSuite() *PathPrefixSuite { - return &PathPrefixSuite{RodSuite: new(RodSuite)} + return &PathPrefixSuite{ + RodSuite: NewRodSuite(pathPrefixSuiteName), + } +} + +func (s *PathPrefixSuite) TestCheckEnv() { + s.Assert().Equal("/auth", GetPathPrefix()) } func (s *PathPrefixSuite) Test1FAScenario() { @@ -30,6 +36,10 @@ func (s *PathPrefixSuite) TestResetPasswordScenario() { suite.Run(s.T(), NewResetPasswordScenario()) } +func (s *PathPrefixSuite) SetupSuite() { + s.T().Setenv("PathPrefix", "/auth") +} + func TestPathPrefixSuite(t *testing.T) { if testing.Short() { t.Skip("skipping suite test in short mode") diff --git a/internal/suites/suite_postgres_test.go b/internal/suites/suite_postgres_test.go index fb737f8c4..4d3fb1c0e 100644 --- a/internal/suites/suite_postgres_test.go +++ b/internal/suites/suite_postgres_test.go @@ -11,7 +11,9 @@ type PostgresSuite struct { } func NewPostgresSuite() *PostgresSuite { - return &PostgresSuite{RodSuite: new(RodSuite)} + return &PostgresSuite{ + RodSuite: NewRodSuite(postgresSuiteName), + } } func (s *PostgresSuite) Test1FAScenario() { diff --git a/internal/suites/suite_short_timeouts_test.go b/internal/suites/suite_short_timeouts_test.go index f59ebdce8..678657e60 100644 --- a/internal/suites/suite_short_timeouts_test.go +++ b/internal/suites/suite_short_timeouts_test.go @@ -11,7 +11,9 @@ type ShortTimeoutsSuite struct { } func NewShortTimeoutsSuite() *ShortTimeoutsSuite { - return &ShortTimeoutsSuite{RodSuite: new(RodSuite)} + return &ShortTimeoutsSuite{ + RodSuite: NewRodSuite(shortTimeoutsSuiteName), + } } func (s *ShortTimeoutsSuite) TestDefaultRedirectionURLScenario() { diff --git a/internal/suites/suite_standalone_test.go b/internal/suites/suite_standalone_test.go index e126b3dd8..79e9e3d8f 100644 --- a/internal/suites/suite_standalone_test.go +++ b/internal/suites/suite_standalone_test.go @@ -22,10 +22,14 @@ type StandaloneWebDriverSuite struct { } func NewStandaloneWebDriverSuite() *StandaloneWebDriverSuite { - return &StandaloneWebDriverSuite{RodSuite: new(RodSuite)} + return &StandaloneWebDriverSuite{ + RodSuite: NewRodSuite(""), + } } func (s *StandaloneWebDriverSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + browser, err := StartRod() if err != nil { @@ -169,11 +173,15 @@ func (s *StandaloneWebDriverSuite) TestShouldCheckUserIsAskedToRegisterDevice() } type StandaloneSuite struct { - suite.Suite + *BaseSuite } func NewStandaloneSuite() *StandaloneSuite { - return &StandaloneSuite{} + return &StandaloneSuite{ + BaseSuite: &BaseSuite{ + Name: standaloneSuiteName, + }, + } } func (s *StandaloneSuite) TestShouldRespectMethodsACL() { @@ -266,7 +274,7 @@ func (s *StandaloneSuite) TestShouldVerifyAPIVerifyRedirectFromXOriginalURL() { s.Assert().NoError(err) urlEncodedAdminURL := url.QueryEscape(AdminBaseURL) - s.Assert().Equal(fmt.Sprintf("302 Found", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body)) + s.Assert().Equal(fmt.Sprintf("302 Found", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s&rm=GET", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body)) } func (s *StandaloneSuite) TestShouldVerifyAPIVerifyRedirectFromXOriginalHostURI() { @@ -285,7 +293,7 @@ func (s *StandaloneSuite) TestShouldVerifyAPIVerifyRedirectFromXOriginalHostURI( s.Assert().NoError(err) urlEncodedAdminURL := url.QueryEscape(SecureBaseURL + "/") - s.Assert().Equal(fmt.Sprintf("302 Found", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body)) + s.Assert().Equal(fmt.Sprintf("302 Found", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s&rm=GET", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body)) } func (s *StandaloneSuite) TestShouldRecordMetrics() { diff --git a/internal/suites/suite_traefik2_test.go b/internal/suites/suite_traefik2_test.go index 9a057134c..af51ea748 100644 --- a/internal/suites/suite_traefik2_test.go +++ b/internal/suites/suite_traefik2_test.go @@ -14,7 +14,9 @@ type Traefik2Suite struct { } func NewTraefik2Suite() *Traefik2Suite { - return &Traefik2Suite{RodSuite: new(RodSuite)} + return &Traefik2Suite{ + RodSuite: NewRodSuite(traefik2SuiteName), + } } func (s *Traefik2Suite) Test1FAScenario() { diff --git a/internal/suites/suite_traefik_test.go b/internal/suites/suite_traefik_test.go index 195184673..8b183e44f 100644 --- a/internal/suites/suite_traefik_test.go +++ b/internal/suites/suite_traefik_test.go @@ -11,7 +11,9 @@ type TraefikSuite struct { } func NewTraefikSuite() *TraefikSuite { - return &TraefikSuite{RodSuite: new(RodSuite)} + return &TraefikSuite{ + RodSuite: NewRodSuite(traefikSuiteName), + } } func (s *TraefikSuite) Test1FAScenario() { diff --git a/internal/suites/suites.go b/internal/suites/suites.go index 4595cf524..96ebf4633 100644 --- a/internal/suites/suites.go +++ b/internal/suites/suites.go @@ -5,17 +5,31 @@ import ( "github.com/stretchr/testify/suite" ) +func NewRodSuite(name string) *RodSuite { + return &RodSuite{ + BaseSuite: &BaseSuite{ + Name: name, + }, + } +} + // RodSuite is a go-rod suite. type RodSuite struct { - suite.Suite + *BaseSuite *RodSession *rod.Page } +type BaseSuite struct { + suite.Suite + + Name string +} + // CommandSuite is a command line interface suite. type CommandSuite struct { - suite.Suite + *BaseSuite testArg string //nolint:structcheck // TODO: Remove when bug fixed: https://github.com/golangci/golangci-lint/issues/537. coverageArg string //nolint:structcheck // TODO: Remove when bug fixed: https://github.com/golangci/golangci-lint/issues/537. diff --git a/internal/suites/utils.go b/internal/suites/utils.go index 6d96d05ec..4ed46ad91 100644 --- a/internal/suites/utils.go +++ b/internal/suites/utils.go @@ -1,12 +1,12 @@ package suites import ( + "bufio" "context" "crypto/tls" "encoding/json" "fmt" "io" - "log" "net/http" "os" "path/filepath" @@ -16,15 +16,55 @@ import ( "github.com/go-rod/rod" "github.com/google/uuid" + log "github.com/sirupsen/logrus" ) +var browserPaths = []string{"/usr/bin/chromium-browser", "/usr/bin/chromium"} + +// ValidateBrowserPath validates the appropriate chromium browser path. +func ValidateBrowserPath(path string) (browserPath string, err error) { + var info os.FileInfo + + if info, err = os.Stat(path); err != nil { + return "", err + } else if info.IsDir() { + return "", fmt.Errorf("browser cannot be a directory") + } + + return path, nil +} + +// GetBrowserPath retrieves the appropriate chromium browser path. +func GetBrowserPath() (path string, err error) { + browserPath := os.Getenv("BROWSER_PATH") + + if browserPath != "" { + return ValidateBrowserPath(browserPath) + } + + for _, browserPath = range browserPaths { + if browserPath, err = ValidateBrowserPath(browserPath); err == nil { + return browserPath, nil + } + } + + return "", fmt.Errorf("no chromium browser was detected in the known paths, set the BROWSER_PATH environment variable to override the path") +} + // GetLoginBaseURL returns the URL of the login portal and the path prefix if specified. func GetLoginBaseURL(baseDomain string) string { - if PathPrefix != "" { - return LoginBaseURLFmt(baseDomain) + PathPrefix + return LoginBaseURLFmt(baseDomain) + GetPathPrefix() +} + +// GetLoginBaseURLWithFallbackPrefix overloads GetLoginBaseURL and includes '/' as a prefix if the prefix is empty. +func GetLoginBaseURLWithFallbackPrefix(baseDomain, fallback string) string { + prefix := GetPathPrefix() + + if prefix == "" { + prefix = fallback } - return LoginBaseURLFmt(baseDomain) + return LoginBaseURLFmt(baseDomain) + prefix } func (rs *RodSession) collectCoverage(page *rod.Page) { @@ -52,6 +92,87 @@ func (rs *RodSession) collectCoverage(page *rod.Page) { } } +func (s *BaseSuite) SetupSuite() { + s.SetupLogging() + s.SetupEnvironment() +} + +func (s *BaseSuite) SetupLogging() { + if os.Getenv("SUITE_SETUP_LOGGING") == t { + return + } + + var ( + level string + ok bool + ) + + if level, ok = os.LookupEnv("SUITES_LOG_LEVEL"); !ok { + return + } + + l, err := log.ParseLevel(level) + + s.NoError(err) + + log.SetLevel(l) + + log.SetFormatter(&log.TextFormatter{ + ForceColors: true, + }) + + s.T().Setenv("SUITE_SETUP_LOGGING", t) +} + +func (s *BaseSuite) SetupEnvironment() { + if s.Name == "" || os.Getenv("SUITE_SETUP_ENVIRONMENT") == t { + return + } + + log.Debugf("Checking Suite %s for .env file", s.Name) + + path := filepath.Join(s.Name, ".env") + + var ( + info os.FileInfo + err error + ) + + path, err = filepath.Abs(path) + + s.Require().NoError(err) + + if info, err = os.Stat(path); err != nil { + s.Assert().True(os.IsNotExist(err)) + + log.Debugf("Suite %s does not have an .env file or it can't be read: %v", s.Name, err) + + return + } + + s.Require().False(info.IsDir()) + + log.Debugf("Suite %s does have an .env file at path: %s", s.Name, path) + + var file *os.File + + file, err = os.Open(path) + + s.Require().NoError(err) + + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + v := strings.Split(scanner.Text(), "=") + + s.Require().Len(v, 2) + + s.T().Setenv(v[0], v[1]) + } + + s.T().Setenv("SUITE_SETUP_ENVIRONMENT", t) +} + func (rs *RodSession) collectScreenshot(err error, page *rod.Page) { if err == context.DeadlineExceeded && os.Getenv("CI") == t { base := "/buildkite/screenshots" @@ -73,6 +194,17 @@ func (rs *RodSession) collectScreenshot(err error, page *rod.Page) { } } +func (s *RodSuite) GetCookieNames() (names []string) { + cookies, err := s.Page.Cookies(nil) + s.Require().NoError(err) + + for _, cookie := range cookies { + names = append(names, cookie.Name) + } + + return names +} + func fixCoveragePath(path string, file os.FileInfo, err error) error { if err != nil { return err @@ -108,7 +240,7 @@ func fixCoveragePath(path string, file os.FileInfo, err error) error { } // getEnvInfoFromURL gets environments variables for specified cookie domain -// this func makes a http call to https://login./override and is only useful for suite tests. +// this func makes a http call to https://login./devworkflow and is only useful for suite tests. func getDomainEnvInfo(domain string) (map[string]string, error) { info := make(map[string]string) @@ -154,18 +286,12 @@ func getDomainEnvInfo(domain string) (map[string]string, error) { // generateDevEnvFile generates web/.env.development based on opts. func generateDevEnvFile(opts map[string]string) error { - wd, _ := os.Getwd() - path := strings.TrimSuffix(wd, "internal/suites") - - src := fmt.Sprintf("%s/web/.env.production", path) - dst := fmt.Sprintf("%s/web/.env.development", path) - - tmpl, err := template.ParseFiles(src) + tmpl, err := template.ParseFiles(envFileProd) if err != nil { return err } - file, _ := os.Create(dst) + file, _ := os.Create(envFileDev) defer file.Close() if err := tmpl.Execute(file, opts); err != nil { @@ -178,10 +304,15 @@ func generateDevEnvFile(opts map[string]string) error { // updateDevEnvFileForDomain updates web/.env.development. // this function only affects local dev environments. func updateDevEnvFileForDomain(domain string, setup bool) error { - if os.Getenv("CI") == "true" { + if os.Getenv("CI") == t { return nil } + if _, err := os.Stat(envFileDev); err != nil && os.IsNotExist(err) { + file, _ := os.Create(envFileDev) + file.Close() + } + info, err := getDomainEnvInfo(domain) if err != nil { return err diff --git a/internal/suites/verify_url_is.go b/internal/suites/verify_url_is.go index 10c59c62f..1b269a188 100644 --- a/internal/suites/verify_url_is.go +++ b/internal/suites/verify_url_is.go @@ -1,6 +1,7 @@ package suites import ( + "regexp" "testing" "github.com/go-rod/rod" @@ -11,3 +12,9 @@ func (rs *RodSession) verifyURLIs(t *testing.T, page *rod.Page, url string) { currentURL := page.MustInfo().URL require.Equal(t, url, currentURL, "they should be equal") } + +func (rs *RodSession) verifyURLIsRegexp(t *testing.T, page *rod.Page, rx *regexp.Regexp) { + currentURL := page.MustInfo().URL + + require.Regexp(t, rx, currentURL, "url should match the expression") +} diff --git a/internal/suites/webdriver.go b/internal/suites/webdriver.go index 79d0d94ad..da14b607b 100644 --- a/internal/suites/webdriver.go +++ b/internal/suites/webdriver.go @@ -19,10 +19,11 @@ type RodSession struct { } // StartRodWithProxy create a rod/chromedp session. -func StartRodWithProxy(proxy string) (*RodSession, error) { - browserPath := os.Getenv("BROWSER_PATH") - if browserPath == "" { - browserPath = "/usr/bin/chromium-browser" +func StartRodWithProxy(proxy string) (session *RodSession, err error) { + var browserPath string + + if browserPath, err = GetBrowserPath(); err != nil { + return nil, err } headless := false diff --git a/internal/utils/bytes.go b/internal/utils/bytes.go new file mode 100644 index 000000000..c8443728d --- /dev/null +++ b/internal/utils/bytes.go @@ -0,0 +1,28 @@ +package utils + +// BytesJoin is an alternate form of bytes.Join which doesn't use a sep. +func BytesJoin(s ...[]byte) (dst []byte) { + if len(s) == 0 { + return []byte{} + } + + if len(s) == 1 { + return append([]byte(nil), s[0]...) + } + + var ( + n, dstp int + ) + + for _, v := range s { + n += len(v) + } + + dst = make([]byte, n) + + for _, v := range s { + dstp += copy(dst[dstp:], v) + } + + return dst +} diff --git a/internal/utils/crypto_test.go b/internal/utils/crypto_test.go index dcc065721..dc7889996 100644 --- a/internal/utils/crypto_test.go +++ b/internal/utils/crypto_test.go @@ -36,7 +36,9 @@ func TestShouldReturnErrWhenX509DirectoryNotExist(t *testing.T) { } func TestShouldNotReturnErrWhenX509DirectoryExist(t *testing.T) { - pool, warnings, errors := NewX509CertPool("/tmp") + dir := t.TempDir() + + pool, warnings, errors := NewX509CertPool(dir) assert.NotNil(t, pool) if runtime.GOOS == windows { diff --git a/web/.env.production b/web/.env.production index 39ae4c29e..8a98edcac 100644 --- a/web/.env.production +++ b/web/.env.production @@ -4,4 +4,6 @@ VITE_DUO_SELF_ENROLLMENT={{ .DuoSelfEnrollment }} VITE_REMEMBER_ME={{ .RememberMe }} VITE_RESET_PASSWORD={{ .ResetPassword }} VITE_RESET_PASSWORD_CUSTOM_URL={{ .ResetPasswordCustomURL }} +VITE_PRIVACY_POLICY_URL={{ .PrivacyPolicyURL }} +VITE_PRIVACY_POLICY_ACCEPT={{ .PrivacyPolicyAccept }} VITE_THEME={{ .Theme }} diff --git a/web/index.html b/web/index.html index fc961d145..ea719dfed 100644 --- a/web/index.html +++ b/web/index.html @@ -19,6 +19,8 @@ data-rememberme="%VITE_REMEMBER_ME%" data-resetpassword="%VITE_RESET_PASSWORD%" data-resetpasswordcustomurl="%VITE_RESET_PASSWORD_CUSTOM_URL%" + data-privacypolicyurl="%VITE_PRIVACY_POLICY_URL%" + data-privacypolicyaccept="%VITE_PRIVACY_POLICY_ACCEPT%" data-theme="%VITE_THEME%" > diff --git a/web/package.json b/web/package.json index d5395f3c2..2bee165a7 100644 --- a/web/package.json +++ b/web/package.json @@ -26,9 +26,9 @@ "@fortawesome/free-solid-svg-icons": "6.2.1", "@fortawesome/react-fontawesome": "0.2.0", "@mui/icons-material": "5.11.0", - "@mui/material": "5.11.5", + "@mui/material": "5.11.6", "@mui/styles": "5.11.2", - "axios": "1.2.3", + "axios": "1.2.4", "broadcast-channel": "4.20.2", "classnames": "2.3.2", "i18next": "22.4.9", @@ -147,16 +147,16 @@ "@limegrass/eslint-plugin-import-alias": "1.0.6", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.4.0", - "@types/jest": "29.2.6", + "@types/jest": "29.4.0", "@types/node": "18.11.18", "@types/qrcode.react": "1.0.2", "@types/react": "18.0.27", "@types/react-dom": "18.0.10", "@types/zxcvbn": "4.4.1", - "@typescript-eslint/eslint-plugin": "5.48.2", - "@typescript-eslint/parser": "5.48.2", + "@typescript-eslint/eslint-plugin": "5.49.0", + "@typescript-eslint/parser": "5.49.0", "@vitejs/plugin-react": "3.0.1", - "esbuild": "0.17.3", + "esbuild": "0.17.4", "esbuild-jest": "0.5.0", "eslint": "8.32.0", "eslint-config-prettier": "8.6.0", @@ -169,10 +169,10 @@ "eslint-plugin-react": "7.32.1", "eslint-plugin-react-hooks": "4.6.0", "husky": "8.0.3", - "jest": "29.3.1", - "jest-environment-jsdom": "29.3.1", + "jest": "29.4.0", + "jest-environment-jsdom": "29.4.0", "jest-transform-stub": "2.0.0", - "jest-watch-typeahead": "2.2.1", + "jest-watch-typeahead": "2.2.2", "prettier": "2.8.3", "react-test-renderer": "18.2.0", "typescript": "4.9.4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 663973404..8f873ad2d 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -12,23 +12,23 @@ specifiers: '@fortawesome/react-fontawesome': 0.2.0 '@limegrass/eslint-plugin-import-alias': 1.0.6 '@mui/icons-material': 5.11.0 - '@mui/material': 5.11.5 + '@mui/material': 5.11.6 '@mui/styles': 5.11.2 '@testing-library/jest-dom': 5.16.5 '@testing-library/react': 13.4.0 - '@types/jest': 29.2.6 + '@types/jest': 29.4.0 '@types/node': 18.11.18 '@types/qrcode.react': 1.0.2 '@types/react': 18.0.27 '@types/react-dom': 18.0.10 '@types/zxcvbn': 4.4.1 - '@typescript-eslint/eslint-plugin': 5.48.2 - '@typescript-eslint/parser': 5.48.2 + '@typescript-eslint/eslint-plugin': 5.49.0 + '@typescript-eslint/parser': 5.49.0 '@vitejs/plugin-react': 3.0.1 - axios: 1.2.3 + axios: 1.2.4 broadcast-channel: 4.20.2 classnames: 2.3.2 - esbuild: 0.17.3 + esbuild: 0.17.4 esbuild-jest: 0.5.0 eslint: 8.32.0 eslint-config-prettier: 8.6.0 @@ -44,10 +44,10 @@ specifiers: i18next: 22.4.9 i18next-browser-languagedetector: 7.0.1 i18next-http-backend: 2.1.1 - jest: 29.3.1 - jest-environment-jsdom: 29.3.1 + jest: 29.4.0 + jest-environment-jsdom: 29.4.0 jest-transform-stub: 2.0.0 - jest-watch-typeahead: 2.2.1 + jest-watch-typeahead: 2.2.2 prettier: 2.8.3 qrcode.react: 3.1.0 react: 18.2.0 @@ -73,10 +73,10 @@ dependencies: '@fortawesome/free-regular-svg-icons': 6.2.1 '@fortawesome/free-solid-svg-icons': 6.2.1 '@fortawesome/react-fontawesome': 0.2.0_z27bm67dtmuyyvss23ckjdrcuy - '@mui/icons-material': 5.11.0_6knf4eskaeuswjfh2cm3sznyza - '@mui/material': 5.11.5_rqh7qj4464ntrqrt6banhaqg4q + '@mui/icons-material': 5.11.0_j5wvuqirnhynb4halegp2mqooy + '@mui/material': 5.11.6_rqh7qj4464ntrqrt6banhaqg4q '@mui/styles': 5.11.2_3stiutgnnbnfnf3uowm5cip22i - axios: 1.2.3 + axios: 1.2.4 broadcast-channel: 4.20.2 classnames: 2.3.2 i18next: 22.4.9 @@ -97,32 +97,32 @@ devDependencies: '@limegrass/eslint-plugin-import-alias': 1.0.6_eslint@8.32.0 '@testing-library/jest-dom': 5.16.5 '@testing-library/react': 13.4.0_biqbaboplfbrettd7655fr4n2y - '@types/jest': 29.2.6 + '@types/jest': 29.4.0 '@types/node': 18.11.18 '@types/qrcode.react': 1.0.2 '@types/react': 18.0.27 '@types/react-dom': 18.0.10 '@types/zxcvbn': 4.4.1 - '@typescript-eslint/eslint-plugin': 5.48.2_caon6io6stgpr7lz2rtbhekxqy - '@typescript-eslint/parser': 5.48.2_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/eslint-plugin': 5.49.0_iu322prlnwsygkcra5kbpy22si + '@typescript-eslint/parser': 5.49.0_7uibuqfxkfaozanbtbziikiqje '@vitejs/plugin-react': 3.0.1_vite@4.0.4 - esbuild: 0.17.3 - esbuild-jest: 0.5.0_esbuild@0.17.3 + esbuild: 0.17.4 + esbuild-jest: 0.5.0_esbuild@0.17.4 eslint: 8.32.0 eslint-config-prettier: 8.6.0_eslint@8.32.0 - eslint-config-react-app: 7.0.1_tag7jkv7r4lhoczarutep4so5q + eslint-config-react-app: 7.0.1_62kxqxvehyyeedrlmmzivzr77q eslint-formatter-rdjson: 1.0.5 eslint-import-resolver-typescript: 3.5.3_ps7hf4l2dvbuxvtusmrfhmzsba - eslint-plugin-import: 2.27.5_bzolr7xl6xcwr64wsu2tr4eimm + eslint-plugin-import: 2.27.5_tto3jvfrcbe7ndbi56p7uxhaki eslint-plugin-jsx-a11y: 6.7.1_eslint@8.32.0 eslint-plugin-prettier: 4.2.1_cn4lalcyadplruoxa5mhp7j3dq eslint-plugin-react: 7.32.1_eslint@8.32.0 eslint-plugin-react-hooks: 4.6.0_eslint@8.32.0 husky: 8.0.3 - jest: 29.3.1_@types+node@18.11.18 - jest-environment-jsdom: 29.3.1 + jest: 29.4.0_@types+node@18.11.18 + jest-environment-jsdom: 29.4.0 jest-transform-stub: 2.0.0 - jest-watch-typeahead: 2.2.1_jest@29.3.1 + jest-watch-typeahead: 2.2.2_jest@29.4.0 prettier: 2.8.3 react-test-renderer: 18.2.0_react@18.2.0 typescript: 4.9.4 @@ -163,14 +163,14 @@ packages: dependencies: '@ampproject/remapping': 2.2.0 '@babel/code-frame': 7.18.6 - '@babel/generator': 7.20.5 - '@babel/helper-compilation-targets': 7.20.0_@babel+core@7.18.6 - '@babel/helper-module-transforms': 7.20.2 - '@babel/helpers': 7.20.6 + '@babel/generator': 7.20.7 + '@babel/helper-compilation-targets': 7.20.7_@babel+core@7.18.6 + '@babel/helper-module-transforms': 7.20.11 + '@babel/helpers': 7.20.7 '@babel/parser': 7.20.7 - '@babel/template': 7.18.10 - '@babel/traverse': 7.20.5 - '@babel/types': 7.20.5 + '@babel/template': 7.20.7 + '@babel/traverse': 7.20.12 + '@babel/types': 7.20.7 convert-source-map: 1.8.0 debug: 4.3.4 gensync: 1.0.0-beta.2 @@ -220,15 +220,6 @@ packages: semver: 6.3.0 dev: true - /@babel/generator/7.20.5: - resolution: {integrity: sha512-jl7JY2Ykn9S0yj4DQP82sYvPU+T3g0HFcWTqDLqiuA9tGRNIj9VfbtXGAYTTkyNEnQk1jkMGOdYka8aG/lulCA==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.20.7 - '@jridgewell/gen-mapping': 0.3.2 - jsesc: 2.5.2 - dev: true - /@babel/generator/7.20.7: resolution: {integrity: sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==} engines: {node: '>=6.9.0'} @@ -253,8 +244,8 @@ packages: '@babel/types': 7.20.7 dev: true - /@babel/helper-compilation-targets/7.20.0_@babel+core@7.18.6: - resolution: {integrity: sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ==} + /@babel/helper-compilation-targets/7.20.7_@babel+core@7.18.6: + resolution: {integrity: sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -266,6 +257,7 @@ packages: '@babel/core': 7.18.6 '@babel/helper-validator-option': 7.18.6 browserslist: 4.21.4 + lru-cache: 5.1.1 semver: 6.3.0 dev: true @@ -398,22 +390,6 @@ packages: - supports-color dev: true - /@babel/helper-module-transforms/7.20.2: - resolution: {integrity: sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-environment-visitor': 7.18.9 - '@babel/helper-module-imports': 7.18.6 - '@babel/helper-simple-access': 7.20.2 - '@babel/helper-split-export-declaration': 7.18.6 - '@babel/helper-validator-identifier': 7.19.1 - '@babel/template': 7.20.7 - '@babel/traverse': 7.20.12 - '@babel/types': 7.20.7 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helper-optimise-call-expression/7.18.6: resolution: {integrity: sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==} engines: {node: '>=6.9.0'} @@ -460,7 +436,7 @@ packages: resolution: {integrity: sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.20.5 + '@babel/types': 7.20.7 dev: true /@babel/helper-skip-transparent-expression-wrappers/7.18.6: @@ -502,17 +478,6 @@ packages: - supports-color dev: true - /@babel/helpers/7.20.6: - resolution: {integrity: sha512-Pf/OjgfgFRW5bApskEz5pvidpim7tEDPlFtKcNRXWmfHGn9IEI2W2flqRQXTFb7gIPTyK++N6rVHuwKut4XK6w==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.20.7 - '@babel/traverse': 7.20.12 - '@babel/types': 7.20.7 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helpers/7.20.7: resolution: {integrity: sha512-PBPjs5BppzsGaxHQCDKnZ6Gd9s6xl8bBCluz3vEInLGRJmnZan4F6BYCeqtyXqkk4W5IlPmjK4JlOuZkpJ3xZA==} engines: {node: '>=6.9.0'} @@ -1473,7 +1438,7 @@ packages: optional: true dependencies: '@babel/core': 7.18.6 - '@babel/helper-module-transforms': 7.20.2 + '@babel/helper-module-transforms': 7.20.11 '@babel/helper-plugin-utils': 7.19.0 '@babel/helper-simple-access': 7.20.2 babel-plugin-dynamic-import-node: 2.3.3 @@ -1491,7 +1456,7 @@ packages: optional: true dependencies: '@babel/core': 7.20.12 - '@babel/helper-module-transforms': 7.20.2 + '@babel/helper-module-transforms': 7.20.11 '@babel/helper-plugin-utils': 7.19.0 '@babel/helper-simple-access': 7.20.2 babel-plugin-dynamic-import-node: 2.3.3 @@ -2010,15 +1975,6 @@ packages: dependencies: regenerator-runtime: 0.13.11 - /@babel/template/7.18.10: - resolution: {integrity: sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.18.6 - '@babel/parser': 7.20.7 - '@babel/types': 7.20.7 - dev: true - /@babel/template/7.20.7: resolution: {integrity: sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==} engines: {node: '>=6.9.0'} @@ -2046,33 +2002,6 @@ packages: - supports-color dev: true - /@babel/traverse/7.20.5: - resolution: {integrity: sha512-WM5ZNN3JITQIq9tFZaw1ojLU3WgWdtkxnhM1AegMS+PvHjkM5IXjmYEGY7yukz5XS4sJyEf2VzWjI8uAavhxBQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.18.6 - '@babel/generator': 7.20.7 - '@babel/helper-environment-visitor': 7.18.9 - '@babel/helper-function-name': 7.19.0 - '@babel/helper-hoist-variables': 7.18.6 - '@babel/helper-split-export-declaration': 7.18.6 - '@babel/parser': 7.20.7 - '@babel/types': 7.20.7 - debug: 4.3.4 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - dev: true - - /@babel/types/7.20.5: - resolution: {integrity: sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.19.4 - '@babel/helper-validator-identifier': 7.19.1 - to-fast-properties: 2.0.0 - dev: true - /@babel/types/7.20.7: resolution: {integrity: sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==} engines: {node: '>=6.9.0'} @@ -2406,8 +2335,8 @@ packages: dev: true optional: true - /@esbuild/android-arm/0.17.3: - resolution: {integrity: sha512-1Mlz934GvbgdDmt26rTLmf03cAgLg5HyOgJN+ZGCeP3Q9ynYTNMn2/LQxIl7Uy+o4K6Rfi2OuLsr12JQQR8gNg==} + /@esbuild/android-arm/0.17.4: + resolution: {integrity: sha512-R9GCe2xl2XDSc2XbQB63mFiFXHIVkOP+ltIxICKXqUPrFX97z6Z7vONCLQM1pSOLGqfLrGi3B7nbhxmFY/fomg==} engines: {node: '>=12'} cpu: [arm] os: [android] @@ -2424,8 +2353,8 @@ packages: dev: true optional: true - /@esbuild/android-arm64/0.17.3: - resolution: {integrity: sha512-XvJsYo3dO3Pi4kpalkyMvfQsjxPWHYjoX4MDiB/FUM4YMfWcXa5l4VCwFWVYI1+92yxqjuqrhNg0CZg3gSouyQ==} + /@esbuild/android-arm64/0.17.4: + resolution: {integrity: sha512-91VwDrl4EpxBCiG6h2LZZEkuNvVZYJkv2T9gyLG/mhGG1qrM7i5SwUcg/hlSPnL/4hDT0TFcF35/XMGSn0bemg==} engines: {node: '>=12'} cpu: [arm64] os: [android] @@ -2442,8 +2371,8 @@ packages: dev: true optional: true - /@esbuild/android-x64/0.17.3: - resolution: {integrity: sha512-nuV2CmLS07Gqh5/GrZLuqkU9Bm6H6vcCspM+zjp9TdQlxJtIe+qqEXQChmfc7nWdyr/yz3h45Utk1tUn8Cz5+A==} + /@esbuild/android-x64/0.17.4: + resolution: {integrity: sha512-mGSqhEPL7029XL7QHNPxPs15JVa02hvZvysUcyMP9UXdGFwncl2WU0bqx+Ysgzd+WAbv8rfNa73QveOxAnAM2w==} engines: {node: '>=12'} cpu: [x64] os: [android] @@ -2460,8 +2389,8 @@ packages: dev: true optional: true - /@esbuild/darwin-arm64/0.17.3: - resolution: {integrity: sha512-01Hxaaat6m0Xp9AXGM8mjFtqqwDjzlMP0eQq9zll9U85ttVALGCGDuEvra5Feu/NbP5AEP1MaopPwzsTcUq1cw==} + /@esbuild/darwin-arm64/0.17.4: + resolution: {integrity: sha512-tTyJRM9dHvlMPt1KrBFVB5OW1kXOsRNvAPtbzoKazd5RhD5/wKlXk1qR2MpaZRYwf4WDMadt0Pv0GwxB41CVow==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] @@ -2478,8 +2407,8 @@ packages: dev: true optional: true - /@esbuild/darwin-x64/0.17.3: - resolution: {integrity: sha512-Eo2gq0Q/er2muf8Z83X21UFoB7EU6/m3GNKvrhACJkjVThd0uA+8RfKpfNhuMCl1bKRfBzKOk6xaYKQZ4lZqvA==} + /@esbuild/darwin-x64/0.17.4: + resolution: {integrity: sha512-phQuC2Imrb3TjOJwLN8EO50nb2FHe8Ew0OwgZDH1SV6asIPGudnwTQtighDF2EAYlXChLoMJwqjAp4vAaACq6w==} engines: {node: '>=12'} cpu: [x64] os: [darwin] @@ -2496,8 +2425,8 @@ packages: dev: true optional: true - /@esbuild/freebsd-arm64/0.17.3: - resolution: {integrity: sha512-CN62ESxaquP61n1ZjQP/jZte8CE09M6kNn3baos2SeUfdVBkWN5n6vGp2iKyb/bm/x4JQzEvJgRHLGd5F5b81w==} + /@esbuild/freebsd-arm64/0.17.4: + resolution: {integrity: sha512-oH6JUZkocgmjzzYaP5juERLpJQSwazdjZrTPgLRmAU2bzJ688x0vfMB/WTv4r58RiecdHvXOPC46VtsMy/mepg==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] @@ -2514,8 +2443,8 @@ packages: dev: true optional: true - /@esbuild/freebsd-x64/0.17.3: - resolution: {integrity: sha512-feq+K8TxIznZE+zhdVurF3WNJ/Sa35dQNYbaqM/wsCbWdzXr5lyq+AaTUSER2cUR+SXPnd/EY75EPRjf4s1SLg==} + /@esbuild/freebsd-x64/0.17.4: + resolution: {integrity: sha512-U4iWGn/9TrAfpAdfd56eO0pRxIgb0a8Wj9jClrhT8hvZnOnS4dfMPW7o4fn15D/KqoiVYHRm43jjBaTt3g/2KA==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] @@ -2532,8 +2461,8 @@ packages: dev: true optional: true - /@esbuild/linux-arm/0.17.3: - resolution: {integrity: sha512-CLP3EgyNuPcg2cshbwkqYy5bbAgK+VhyfMU7oIYyn+x4Y67xb5C5ylxsNUjRmr8BX+MW3YhVNm6Lq6FKtRTWHQ==} + /@esbuild/linux-arm/0.17.4: + resolution: {integrity: sha512-S2s9xWTGMTa/fG5EyMGDeL0wrWVgOSQcNddJWgu6rG1NCSXJHs76ZP9AsxjB3f2nZow9fWOyApklIgiTGZKhiw==} engines: {node: '>=12'} cpu: [arm] os: [linux] @@ -2550,8 +2479,8 @@ packages: dev: true optional: true - /@esbuild/linux-arm64/0.17.3: - resolution: {integrity: sha512-JHeZXD4auLYBnrKn6JYJ0o5nWJI9PhChA/Nt0G4MvLaMrvXuWnY93R3a7PiXeJQphpL1nYsaMcoV2QtuvRnF/g==} + /@esbuild/linux-arm64/0.17.4: + resolution: {integrity: sha512-UkGfQvYlwOaeYJzZG4cLV0hCASzQZnKNktRXUo3/BMZvdau40AOz9GzmGA063n1piq6VrFFh43apRDQx8hMP2w==} engines: {node: '>=12'} cpu: [arm64] os: [linux] @@ -2568,8 +2497,8 @@ packages: dev: true optional: true - /@esbuild/linux-ia32/0.17.3: - resolution: {integrity: sha512-FyXlD2ZjZqTFh0sOQxFDiWG1uQUEOLbEh9gKN/7pFxck5Vw0qjWSDqbn6C10GAa1rXJpwsntHcmLqydY9ST9ZA==} + /@esbuild/linux-ia32/0.17.4: + resolution: {integrity: sha512-3lqFi4VFo/Vwvn77FZXeLd0ctolIJH/uXkH3yNgEk89Eh6D3XXAC9/iTPEzeEpsNE5IqGIsFa5Z0iPeOh25IyA==} engines: {node: '>=12'} cpu: [ia32] os: [linux] @@ -2586,8 +2515,8 @@ packages: dev: true optional: true - /@esbuild/linux-loong64/0.17.3: - resolution: {integrity: sha512-OrDGMvDBI2g7s04J8dh8/I7eSO+/E7nMDT2Z5IruBfUO/RiigF1OF6xoH33Dn4W/OwAWSUf1s2nXamb28ZklTA==} + /@esbuild/linux-loong64/0.17.4: + resolution: {integrity: sha512-HqpWZkVslDHIwdQ9D+gk7NuAulgQvRxF9no54ut/M55KEb3mi7sQS3GwpPJzSyzzP0UkjQVN7/tbk88/CaX4EQ==} engines: {node: '>=12'} cpu: [loong64] os: [linux] @@ -2604,8 +2533,8 @@ packages: dev: true optional: true - /@esbuild/linux-mips64el/0.17.3: - resolution: {integrity: sha512-DcnUpXnVCJvmv0TzuLwKBC2nsQHle8EIiAJiJ+PipEVC16wHXaPEKP0EqN8WnBe0TPvMITOUlP2aiL5YMld+CQ==} + /@esbuild/linux-mips64el/0.17.4: + resolution: {integrity: sha512-d/nMCKKh/SVDbqR9ju+b78vOr0tNXtfBjcp5vfHONCCOAL9ad8gN9dC/u+UnH939pz7wO+0u/x9y1MaZcb/lKA==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] @@ -2622,8 +2551,8 @@ packages: dev: true optional: true - /@esbuild/linux-ppc64/0.17.3: - resolution: {integrity: sha512-BDYf/l1WVhWE+FHAW3FzZPtVlk9QsrwsxGzABmN4g8bTjmhazsId3h127pliDRRu5674k1Y2RWejbpN46N9ZhQ==} + /@esbuild/linux-ppc64/0.17.4: + resolution: {integrity: sha512-lOD9p2dmjZcNiTU+sGe9Nn6G3aYw3k0HBJies1PU0j5IGfp6tdKOQ6mzfACRFCqXjnBuTqK7eTYpwx09O5LLfg==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] @@ -2640,8 +2569,8 @@ packages: dev: true optional: true - /@esbuild/linux-riscv64/0.17.3: - resolution: {integrity: sha512-WViAxWYMRIi+prTJTyV1wnqd2mS2cPqJlN85oscVhXdb/ZTFJdrpaqm/uDsZPGKHtbg5TuRX/ymKdOSk41YZow==} + /@esbuild/linux-riscv64/0.17.4: + resolution: {integrity: sha512-mTGnwWwVshAjGsd8rP+K6583cPDgxOunsqqldEYij7T5/ysluMHKqUIT4TJHfrDFadUwrghAL6QjER4FeqQXoA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] @@ -2658,8 +2587,8 @@ packages: dev: true optional: true - /@esbuild/linux-s390x/0.17.3: - resolution: {integrity: sha512-Iw8lkNHUC4oGP1O/KhumcVy77u2s6+KUjieUqzEU3XuWJqZ+AY7uVMrrCbAiwWTkpQHkr00BuXH5RpC6Sb/7Ug==} + /@esbuild/linux-s390x/0.17.4: + resolution: {integrity: sha512-AQYuUGp50XM29/N/dehADxvc2bUqDcoqrVuijop1Wv72SyxT6dDB9wjUxuPZm2HwIM876UoNNBMVd+iX/UTKVQ==} engines: {node: '>=12'} cpu: [s390x] os: [linux] @@ -2676,8 +2605,8 @@ packages: dev: true optional: true - /@esbuild/linux-x64/0.17.3: - resolution: {integrity: sha512-0AGkWQMzeoeAtXQRNB3s4J1/T2XbigM2/Mn2yU1tQSmQRmHIZdkGbVq2A3aDdNslPyhb9/lH0S5GMTZ4xsjBqg==} + /@esbuild/linux-x64/0.17.4: + resolution: {integrity: sha512-+AsFBwKgQuhV2shfGgA9YloxLDVjXgUEWZum7glR5lLmV94IThu/u2JZGxTgjYby6kyXEx8lKOqP5rTEVBR0Rw==} engines: {node: '>=12'} cpu: [x64] os: [linux] @@ -2694,8 +2623,8 @@ packages: dev: true optional: true - /@esbuild/netbsd-x64/0.17.3: - resolution: {integrity: sha512-4+rR/WHOxIVh53UIQIICryjdoKdHsFZFD4zLSonJ9RRw7bhKzVyXbnRPsWSfwybYqw9sB7ots/SYyufL1mBpEg==} + /@esbuild/netbsd-x64/0.17.4: + resolution: {integrity: sha512-zD1TKYX9553OiLS/qkXPMlWoELYkH/VkzRYNKEU+GwFiqkq0SuxsKnsCg5UCdxN3cqd+1KZ8SS3R+WG/Hxy2jQ==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] @@ -2712,8 +2641,8 @@ packages: dev: true optional: true - /@esbuild/openbsd-x64/0.17.3: - resolution: {integrity: sha512-cVpWnkx9IYg99EjGxa5Gc0XmqumtAwK3aoz7O4Dii2vko+qXbkHoujWA68cqXjhh6TsLaQelfDO4MVnyr+ODeA==} + /@esbuild/openbsd-x64/0.17.4: + resolution: {integrity: sha512-PY1NjEsLRhPEFFg1AV0/4Or/gR+q2dOb9s5rXcPuCjyHRzbt8vnHJl3vYj+641TgWZzTFmSUnZbzs1zwTzjeqw==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] @@ -2730,8 +2659,8 @@ packages: dev: true optional: true - /@esbuild/sunos-x64/0.17.3: - resolution: {integrity: sha512-RxmhKLbTCDAY2xOfrww6ieIZkZF+KBqG7S2Ako2SljKXRFi+0863PspK74QQ7JpmWwncChY25JTJSbVBYGQk2Q==} + /@esbuild/sunos-x64/0.17.4: + resolution: {integrity: sha512-B3Z7s8QZQW9tKGleMRXvVmwwLPAUoDCHs4WZ2ElVMWiortLJFowU1NjAhXOKjDgC7o9ByeVcwyOlJ+F2r6ZgmQ==} engines: {node: '>=12'} cpu: [x64] os: [sunos] @@ -2748,8 +2677,8 @@ packages: dev: true optional: true - /@esbuild/win32-arm64/0.17.3: - resolution: {integrity: sha512-0r36VeEJ4efwmofxVJRXDjVRP2jTmv877zc+i+Pc7MNsIr38NfsjkQj23AfF7l0WbB+RQ7VUb+LDiqC/KY/M/A==} + /@esbuild/win32-arm64/0.17.4: + resolution: {integrity: sha512-0HCu8R3mY/H5V7N6kdlsJkvrT591bO/oRZy8ztF1dhgNU5xD5tAh5bKByT1UjTGjp/VVBsl1PDQ3L18SfvtnBQ==} engines: {node: '>=12'} cpu: [arm64] os: [win32] @@ -2766,8 +2695,8 @@ packages: dev: true optional: true - /@esbuild/win32-ia32/0.17.3: - resolution: {integrity: sha512-wgO6rc7uGStH22nur4aLFcq7Wh86bE9cOFmfTr/yxN3BXvDEdCSXyKkO+U5JIt53eTOgC47v9k/C1bITWL/Teg==} + /@esbuild/win32-ia32/0.17.4: + resolution: {integrity: sha512-VUjhVDQycse1gLbe06pC/uaA0M+piQXJpdpNdhg8sPmeIZZqu5xPoGWVCmcsOO2gaM2cywuTYTHkXRozo3/Nkg==} engines: {node: '>=12'} cpu: [ia32] os: [win32] @@ -2784,8 +2713,8 @@ packages: dev: true optional: true - /@esbuild/win32-x64/0.17.3: - resolution: {integrity: sha512-FdVl64OIuiKjgXBjwZaJLKp0eaEckifbhn10dXWhysMJkWblg3OEEGKSIyhiD5RSgAya8WzP3DNkngtIg3Nt7g==} + /@esbuild/win32-x64/0.17.4: + resolution: {integrity: sha512-0kLAjs+xN5OjhTt/aUA6t48SfENSCKgGPfExADYTOo/UCn0ivxos9/anUVeSfg+L+2O9xkFxvJXIJfG+Q4sYSg==} engines: {node: '>=12'} cpu: [x64] os: [win32] @@ -2887,20 +2816,20 @@ packages: engines: {node: '>=8'} dev: true - /@jest/console/29.3.1: - resolution: {integrity: sha512-IRE6GD47KwcqA09RIWrabKdHPiKDGgtAL31xDxbi/RjQMsr+lY+ppxmHwY0dUEV3qvvxZzoe5Hl0RXZJOjQNUg==} + /@jest/console/29.4.0: + resolution: {integrity: sha512-xpXud7e/8zo4syxQlAMDz+EQiFsf8/zXDPslBYm+UaSJ5uGTKQHhbSHfECp7Fw1trQtopjYumeved0n3waijhQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/types': 29.3.1 + '@jest/types': 29.4.0 '@types/node': 18.11.18 chalk: 4.1.2 - jest-message-util: 29.3.1 - jest-util: 29.3.1 + jest-message-util: 29.4.0 + jest-util: 29.4.0 slash: 3.0.0 dev: true - /@jest/core/29.3.1: - resolution: {integrity: sha512-0ohVjjRex985w5MmO5L3u5GR1O30DexhBSpuwx2P+9ftyqHdJXnk7IUWiP80oHMvt7ubHCJHxV0a0vlKVuZirw==} + /@jest/core/29.4.0: + resolution: {integrity: sha512-E7oCMcENobBFwQXYjnN2IsuUSpRo5jSv7VYk6O9GyQ5kVAfVSS8819I4W5iCCYvqD6+1TzyzLpeEdZEik81kNw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -2908,32 +2837,32 @@ packages: node-notifier: optional: true dependencies: - '@jest/console': 29.3.1 - '@jest/reporters': 29.3.1 - '@jest/test-result': 29.3.1 - '@jest/transform': 29.3.1 - '@jest/types': 29.3.1 + '@jest/console': 29.4.0 + '@jest/reporters': 29.4.0 + '@jest/test-result': 29.4.0 + '@jest/transform': 29.4.0 + '@jest/types': 29.4.0 '@types/node': 18.11.18 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.3.2 exit: 0.1.2 graceful-fs: 4.2.10 - jest-changed-files: 29.2.0 - jest-config: 29.3.1_@types+node@18.11.18 - jest-haste-map: 29.3.1 - jest-message-util: 29.3.1 + jest-changed-files: 29.4.0 + jest-config: 29.4.0_@types+node@18.11.18 + jest-haste-map: 29.4.0 + jest-message-util: 29.4.0 jest-regex-util: 29.2.0 - jest-resolve: 29.3.1 - jest-resolve-dependencies: 29.3.1 - jest-runner: 29.3.1 - jest-runtime: 29.3.1 - jest-snapshot: 29.3.1 - jest-util: 29.3.1 - jest-validate: 29.3.1 - jest-watcher: 29.3.1 + jest-resolve: 29.4.0 + jest-resolve-dependencies: 29.4.0 + jest-runner: 29.4.0 + jest-runtime: 29.4.0 + jest-snapshot: 29.4.0 + jest-util: 29.4.0 + jest-validate: 29.4.0 + jest-watcher: 29.4.0 micromatch: 4.0.5 - pretty-format: 29.3.1 + pretty-format: 29.4.0 slash: 3.0.0 strip-ansi: 6.0.1 transitivePeerDependencies: @@ -2941,59 +2870,59 @@ packages: - ts-node dev: true - /@jest/environment/29.3.1: - resolution: {integrity: sha512-pMmvfOPmoa1c1QpfFW0nXYtNLpofqo4BrCIk6f2kW4JFeNlHV2t3vd+3iDLf31e2ot2Mec0uqZfmI+U0K2CFag==} + /@jest/environment/29.4.0: + resolution: {integrity: sha512-ocl1VGDcZHfHnYLTqkBY7yXme1bF4x0BevJ9wb6y0sLOSyBCpp8L5fEASChB+wU53WMrIK6kBfGt+ZYoM2kcdw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/fake-timers': 29.3.1 - '@jest/types': 29.3.1 + '@jest/fake-timers': 29.4.0 + '@jest/types': 29.4.0 '@types/node': 18.11.18 - jest-mock: 29.3.1 + jest-mock: 29.4.0 dev: true - /@jest/expect-utils/29.3.1: - resolution: {integrity: sha512-wlrznINZI5sMjwvUoLVk617ll/UYfGIZNxmbU+Pa7wmkL4vYzhV9R2pwVqUh4NWWuLQWkI8+8mOkxs//prKQ3g==} + /@jest/expect-utils/29.4.0: + resolution: {integrity: sha512-w/JzTYIqjmPFIM5OOQHF9CawFx2daw1256Nzj4ZqWX96qRKbCq9WYRVqdySBKHHzuvsXLyTDIF6y61FUyrhmwg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: jest-get-type: 29.2.0 dev: true - /@jest/expect/29.3.1: - resolution: {integrity: sha512-QivM7GlSHSsIAWzgfyP8dgeExPRZ9BIe2LsdPyEhCGkZkoyA+kGsoIzbKAfZCvvRzfZioKwPtCZIt5SaoxYCvg==} + /@jest/expect/29.4.0: + resolution: {integrity: sha512-IiDZYQ/Oi94aBT0nKKKRvNsB5JTyHoGb+G3SiGoDxz90JfL7SLx/z5IjB0fzBRzy7aLFQOCbVJlaC2fIgU6Y9Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - expect: 29.3.1 - jest-snapshot: 29.3.1 + expect: 29.4.0 + jest-snapshot: 29.4.0 transitivePeerDependencies: - supports-color dev: true - /@jest/fake-timers/29.3.1: - resolution: {integrity: sha512-iHTL/XpnDlFki9Tq0Q1GGuVeQ8BHZGIYsvCO5eN/O/oJaRzofG9Xndd9HuSDBI/0ZS79pg0iwn07OMTQ7ngF2A==} + /@jest/fake-timers/29.4.0: + resolution: {integrity: sha512-8sitzN2QrhDwEwH3kKcMMgrv/UIkmm9AUgHixmn4L++GQ0CqVTIztm3YmaIQooLmW3O4GhizNTTCyq3iLbWcMw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/types': 29.3.1 - '@sinonjs/fake-timers': 9.1.2 + '@jest/types': 29.4.0 + '@sinonjs/fake-timers': 10.0.2 '@types/node': 18.11.18 - jest-message-util: 29.3.1 - jest-mock: 29.3.1 - jest-util: 29.3.1 + jest-message-util: 29.4.0 + jest-mock: 29.4.0 + jest-util: 29.4.0 dev: true - /@jest/globals/29.3.1: - resolution: {integrity: sha512-cTicd134vOcwO59OPaB6AmdHQMCtWOe+/DitpTZVxWgMJ+YvXL1HNAmPyiGbSHmF/mXVBkvlm8YYtQhyHPnV6Q==} + /@jest/globals/29.4.0: + resolution: {integrity: sha512-Q64ZRgGMVL40RcYTfD2GvyjK7vJLPSIvi8Yp3usGPNPQ3SCW+UCY9KEH6+sVtBo8LzhcjtCXuZEd7avnj/T0mQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/environment': 29.3.1 - '@jest/expect': 29.3.1 - '@jest/types': 29.3.1 - jest-mock: 29.3.1 + '@jest/environment': 29.4.0 + '@jest/expect': 29.4.0 + '@jest/types': 29.4.0 + jest-mock: 29.4.0 transitivePeerDependencies: - supports-color dev: true - /@jest/reporters/29.3.1: - resolution: {integrity: sha512-GhBu3YFuDrcAYW/UESz1JphEAbvUjaY2vShRZRoRY1mxpCMB3yGSJ4j9n0GxVlEOdCf7qjvUfBCrTUUqhVfbRA==} + /@jest/reporters/29.4.0: + resolution: {integrity: sha512-FjJwrD1XOQq/AXKrvnOSf0RgAs6ziUuGKx8+/R53Jscc629JIhg7/m241gf1shUm/fKKxoHd7aCexcg7kxvkWQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -3002,10 +2931,10 @@ packages: optional: true dependencies: '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.3.1 - '@jest/test-result': 29.3.1 - '@jest/transform': 29.3.1 - '@jest/types': 29.3.1 + '@jest/console': 29.4.0 + '@jest/test-result': 29.4.0 + '@jest/transform': 29.4.0 + '@jest/types': 29.4.0 '@jridgewell/trace-mapping': 0.3.15 '@types/node': 18.11.18 chalk: 4.1.2 @@ -3018,9 +2947,9 @@ packages: istanbul-lib-report: 3.0.0 istanbul-lib-source-maps: 4.0.1 istanbul-reports: 3.1.5 - jest-message-util: 29.3.1 - jest-util: 29.3.1 - jest-worker: 29.3.1 + jest-message-util: 29.4.0 + jest-util: 29.4.0 + jest-worker: 29.4.0 slash: 3.0.0 string-length: 4.0.2 strip-ansi: 6.0.1 @@ -3029,11 +2958,11 @@ packages: - supports-color dev: true - /@jest/schemas/29.0.0: - resolution: {integrity: sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA==} + /@jest/schemas/29.4.0: + resolution: {integrity: sha512-0E01f/gOZeNTG76i5eWWSupvSHaIINrTie7vCyjiYFKgzNdyEGd12BUv4oNBFHOqlHDbtoJi3HrQ38KCC90NsQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@sinclair/typebox': 0.24.19 + '@sinclair/typebox': 0.25.21 dev: true /@jest/source-map/29.2.0: @@ -3045,23 +2974,23 @@ packages: graceful-fs: 4.2.10 dev: true - /@jest/test-result/29.3.1: - resolution: {integrity: sha512-qeLa6qc0ddB0kuOZyZIhfN5q0e2htngokyTWsGriedsDhItisW7SDYZ7ceOe57Ii03sL988/03wAcBh3TChMGw==} + /@jest/test-result/29.4.0: + resolution: {integrity: sha512-EtRklzjpddZU/aBVxJqqejfzfOcnehmjNXufs6u6qwd05kkhXpAPhZdt8bLlQd7cA2nD+JqZQ5Dx9NX5Jh6mjA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/console': 29.3.1 - '@jest/types': 29.3.1 + '@jest/console': 29.4.0 + '@jest/types': 29.4.0 '@types/istanbul-lib-coverage': 2.0.4 collect-v8-coverage: 1.0.1 dev: true - /@jest/test-sequencer/29.3.1: - resolution: {integrity: sha512-IqYvLbieTv20ArgKoAMyhLHNrVHJfzO6ARZAbQRlY4UGWfdDnLlZEF0BvKOMd77uIiIjSZRwq3Jb3Fa3I8+2UA==} + /@jest/test-sequencer/29.4.0: + resolution: {integrity: sha512-pEwIgdfvEgF2lBOYX3DVn3SrvsAZ9FXCHw7+C6Qz87HnoDGQwbAselhWLhpgbxDjs6RC9QUJpFnrLmM5uwZV+g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/test-result': 29.3.1 + '@jest/test-result': 29.4.0 graceful-fs: 4.2.10 - jest-haste-map: 29.3.1 + jest-haste-map: 29.4.0 slash: 3.0.0 dev: true @@ -3088,25 +3017,25 @@ packages: - supports-color dev: true - /@jest/transform/29.3.1: - resolution: {integrity: sha512-8wmCFBTVGYqFNLWfcOWoVuMuKYPUBTnTMDkdvFtAYELwDOl9RGwOsvQWGPFxDJ8AWY9xM/8xCXdqmPK3+Q5Lug==} + /@jest/transform/29.4.0: + resolution: {integrity: sha512-hDjw3jz4GnvbyLMgcFpC9/34QcUhVIzJkBqz7o+3AhgfhGRzGuQppuLf5r/q7lDAAyJ6jzL+SFG7JGsScHOcLQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@babel/core': 7.20.12 - '@jest/types': 29.3.1 + '@jest/types': 29.4.0 '@jridgewell/trace-mapping': 0.3.15 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.10 - jest-haste-map: 29.3.1 + jest-haste-map: 29.4.0 jest-regex-util: 29.2.0 - jest-util: 29.3.1 + jest-util: 29.4.0 micromatch: 4.0.5 pirates: 4.0.5 slash: 3.0.0 - write-file-atomic: 4.0.1 + write-file-atomic: 5.0.0 transitivePeerDependencies: - supports-color dev: true @@ -3122,11 +3051,11 @@ packages: chalk: 4.1.2 dev: true - /@jest/types/29.3.1: - resolution: {integrity: sha512-d0S0jmmTpjnhCmNpApgX3jrUZgZ22ivKJRvL2lli5hpCRoNnp1f85r2/wpKfXuYu8E7Jjh1hGfhPyup1NM5AmA==} + /@jest/types/29.4.0: + resolution: {integrity: sha512-1S2Dt5uQp7R0bGY/L2BpuwCSji7v12kY3o8zqwlkbYBmOY956SKk+zOWqmfhHSINegiAVqOXydAYuWpzX6TYsQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/schemas': 29.0.0 + '@jest/schemas': 29.4.0 '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 '@types/node': 18.11.18 @@ -3192,8 +3121,8 @@ packages: tsconfig-paths: 3.14.1 dev: true - /@mui/base/5.0.0-alpha.114_5ndqzdd6t4rivxsukjv3i3ak2q: - resolution: {integrity: sha512-ZpsG2I+zTOAnVTj3Un7TxD2zKRA2OhEPGMcWs/9ylPlS6VuGQSXowPooZiqarjT7TZ0+1bOe8titk/t8dLFiGw==} + /@mui/base/5.0.0-alpha.115_5ndqzdd6t4rivxsukjv3i3ak2q: + resolution: {integrity: sha512-OGQ84whT/yNYd6xKCGGS6MxqEfjVjk5esXM7HP6bB2Rim7QICUapxZt4nm8q39fpT08rNDkv3xPVqDDwRdRg1g==} engines: {node: '>=12.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || 18 @@ -3216,11 +3145,11 @@ packages: react-is: 18.2.0 dev: false - /@mui/core-downloads-tracker/5.11.5: - resolution: {integrity: sha512-MIuWGjitOsugpRhp64CQY3ZEVMIu9M/L9ioql6QLSkz73+bGIlC9FEhfi670/GZ8pQIIGmtiGGwofYzlwEWjig==} + /@mui/core-downloads-tracker/5.11.6: + resolution: {integrity: sha512-lbD3qdafBOf2dlqKhOcVRxaPAujX+9UlPC6v8iMugMeAXe0TCgU3QbGXY3zrJsu6ex64WYDpH4y1+WOOBmWMuA==} dev: false - /@mui/icons-material/5.11.0_6knf4eskaeuswjfh2cm3sznyza: + /@mui/icons-material/5.11.0_j5wvuqirnhynb4halegp2mqooy: resolution: {integrity: sha512-I2LaOKqO8a0xcLGtIozC9xoXjZAto5G5gh0FYUMAlbsIHNHIjn4Xrw9rvjY20vZonyiGrZNMAlAXYkY6JvhF6A==} engines: {node: '>=12.0.0'} peerDependencies: @@ -3232,13 +3161,13 @@ packages: optional: true dependencies: '@babel/runtime': 7.20.6 - '@mui/material': 5.11.5_rqh7qj4464ntrqrt6banhaqg4q + '@mui/material': 5.11.6_rqh7qj4464ntrqrt6banhaqg4q '@types/react': 18.0.27 react: 18.2.0 dev: false - /@mui/material/5.11.5_rqh7qj4464ntrqrt6banhaqg4q: - resolution: {integrity: sha512-5fzjBbRYaB5MoEpvA32oalAWltOZ3/kSyuovuVmPc6UF6AG42lTtbdMLpdCygurFSGUMZYTg4Cjij52fKlDDgg==} + /@mui/material/5.11.6_rqh7qj4464ntrqrt6banhaqg4q: + resolution: {integrity: sha512-MzkkL5KC2PCkFiv8cLpkzgLUPXSrAtnvJBR0emV7mLVWbkwV3n5832vjBx154B6R032fHjFTziTh7YEb50nK6Q==} engines: {node: '>=12.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -3257,8 +3186,8 @@ packages: '@babel/runtime': 7.20.7 '@emotion/react': 11.10.5_3stiutgnnbnfnf3uowm5cip22i '@emotion/styled': 11.10.5_jrh5enlbqfbnumycmktdqgd6se - '@mui/base': 5.0.0-alpha.114_5ndqzdd6t4rivxsukjv3i3ak2q - '@mui/core-downloads-tracker': 5.11.5 + '@mui/base': 5.0.0-alpha.115_5ndqzdd6t4rivxsukjv3i3ak2q + '@mui/core-downloads-tracker': 5.11.6 '@mui/system': 5.11.5_gzalmy7izvhol7vh4xfy3dq6ua '@mui/types': 7.2.3_@types+react@18.0.27 '@mui/utils': 5.11.2_react@18.2.0 @@ -3466,20 +3395,20 @@ packages: resolution: {integrity: sha512-LwzQKA4vzIct1zNZzBmRKI9QuNpLgTQMEjsQLf3BXuGYb3QPTP4Yjf6mkdX+X1mYttZ808QpOwAzZjv28kq7DA==} dev: true - /@sinclair/typebox/0.24.19: - resolution: {integrity: sha512-gHJu8cdYTD5p4UqmQHrxaWrtb/jkH5imLXzuBypWhKzNkW0qfmgz+w1xaJccWVuJta1YYUdlDiPHXRTR4Ku0MQ==} + /@sinclair/typebox/0.25.21: + resolution: {integrity: sha512-gFukHN4t8K4+wVC+ECqeqwzBDeFeTzBXroBTqE6vcWrQGbEUpHO7LYdG0f4xnvYq4VOEwITSlHlp0JBAIFMS/g==} dev: true - /@sinonjs/commons/1.8.3: - resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==} + /@sinonjs/commons/2.0.0: + resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} dependencies: type-detect: 4.0.8 dev: true - /@sinonjs/fake-timers/9.1.2: - resolution: {integrity: sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==} + /@sinonjs/fake-timers/10.0.2: + resolution: {integrity: sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==} dependencies: - '@sinonjs/commons': 1.8.3 + '@sinonjs/commons': 2.0.0 dev: true /@svgr/babel-plugin-add-jsx-attribute/6.5.1_@babel+core@7.20.12: @@ -3764,11 +3693,11 @@ packages: '@types/istanbul-lib-report': 3.0.0 dev: true - /@types/jest/29.2.6: - resolution: {integrity: sha512-XEUC/Tgw3uMh6Ho8GkUtQ2lPhY5Fmgyp3TdlkTJs1W9VgNxs+Ow/x3Elh8lHQKqCbZL0AubQuqWjHVT033Hhrw==} + /@types/jest/29.4.0: + resolution: {integrity: sha512-VaywcGQ9tPorCX/Jkkni7RWGFfI11whqzs8dvxF41P17Z+z872thvEvlIbznjPJ02kl1HMX3LmLOonsj2n7HeQ==} dependencies: - expect: 29.3.1 - pretty-format: 29.3.1 + expect: 29.4.0 + pretty-format: 29.4.0 dev: true /@types/jsdom/20.0.0: @@ -3854,7 +3783,7 @@ packages: /@types/testing-library__jest-dom/5.14.5: resolution: {integrity: sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==} dependencies: - '@types/jest': 29.2.6 + '@types/jest': 29.4.0 dev: true /@types/tough-cookie/4.0.2: @@ -3881,8 +3810,8 @@ packages: resolution: {integrity: sha512-3NoqvZC2W5gAC5DZbTpCeJ251vGQmgcWIHQJGq2J240HY6ErQ9aWKkwfoKJlHLx+A83WPNTZ9+3cd2ILxbvr1w==} dev: true - /@typescript-eslint/eslint-plugin/5.48.2_caon6io6stgpr7lz2rtbhekxqy: - resolution: {integrity: sha512-sR0Gja9Ky1teIq4qJOl0nC+Tk64/uYdX+mi+5iB//MH8gwyx8e3SOyhEzeLZEFEEfCaLf8KJq+Bd/6je1t+CAg==} + /@typescript-eslint/eslint-plugin/5.49.0_iu322prlnwsygkcra5kbpy22si: + resolution: {integrity: sha512-IhxabIpcf++TBaBa1h7jtOWyon80SXPRLDq0dVz5SLFC/eW6tofkw/O7Ar3lkx5z5U6wzbKDrl2larprp5kk5Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: '@typescript-eslint/parser': ^5.0.0 @@ -3892,10 +3821,10 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/parser': 5.48.2_7uibuqfxkfaozanbtbziikiqje - '@typescript-eslint/scope-manager': 5.48.2 - '@typescript-eslint/type-utils': 5.48.2_7uibuqfxkfaozanbtbziikiqje - '@typescript-eslint/utils': 5.48.2_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/parser': 5.49.0_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/scope-manager': 5.49.0 + '@typescript-eslint/type-utils': 5.49.0_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/utils': 5.49.0_7uibuqfxkfaozanbtbziikiqje debug: 4.3.4 eslint: 8.32.0 ignore: 5.2.0 @@ -3921,8 +3850,8 @@ packages: - typescript dev: true - /@typescript-eslint/parser/5.48.2_7uibuqfxkfaozanbtbziikiqje: - resolution: {integrity: sha512-38zMsKsG2sIuM5Oi/olurGwYJXzmtdsHhn5mI/pQogP+BjYVkK5iRazCQ8RGS0V+YLk282uWElN70zAAUmaYHw==} + /@typescript-eslint/parser/5.49.0_7uibuqfxkfaozanbtbziikiqje: + resolution: {integrity: sha512-veDlZN9mUhGqU31Qiv2qEp+XrJj5fgZpJ8PW30sHU+j/8/e5ruAhLaVDAeznS7A7i4ucb/s8IozpDtt9NqCkZg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3931,9 +3860,9 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 5.48.2 - '@typescript-eslint/types': 5.48.2 - '@typescript-eslint/typescript-estree': 5.48.2_typescript@4.9.4 + '@typescript-eslint/scope-manager': 5.49.0 + '@typescript-eslint/types': 5.49.0 + '@typescript-eslint/typescript-estree': 5.49.0_typescript@4.9.4 debug: 4.3.4 eslint: 8.32.0 typescript: 4.9.4 @@ -3949,16 +3878,16 @@ packages: '@typescript-eslint/visitor-keys': 5.30.6 dev: true - /@typescript-eslint/scope-manager/5.48.2: - resolution: {integrity: sha512-zEUFfonQid5KRDKoI3O+uP1GnrFd4tIHlvs+sTJXiWuypUWMuDaottkJuR612wQfOkjYbsaskSIURV9xo4f+Fw==} + /@typescript-eslint/scope-manager/5.49.0: + resolution: {integrity: sha512-clpROBOiMIzpbWNxCe1xDK14uPZh35u4QaZO1GddilEzoCLAEz4szb51rBpdgurs5k2YzPtJeTEN3qVbG+LRUQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - '@typescript-eslint/types': 5.48.2 - '@typescript-eslint/visitor-keys': 5.48.2 + '@typescript-eslint/types': 5.49.0 + '@typescript-eslint/visitor-keys': 5.49.0 dev: true - /@typescript-eslint/type-utils/5.48.2_7uibuqfxkfaozanbtbziikiqje: - resolution: {integrity: sha512-QVWx7J5sPMRiOMJp5dYshPxABRoZV1xbRirqSk8yuIIsu0nvMTZesKErEA3Oix1k+uvsk8Cs8TGJ6kQ0ndAcew==} + /@typescript-eslint/type-utils/5.49.0_7uibuqfxkfaozanbtbziikiqje: + resolution: {integrity: sha512-eUgLTYq0tR0FGU5g1YHm4rt5H/+V2IPVkP0cBmbhRyEmyGe4XvJ2YJ6sYTmONfjmdMqyMLad7SB8GvblbeESZA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: '*' @@ -3967,8 +3896,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.48.2_typescript@4.9.4 - '@typescript-eslint/utils': 5.48.2_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/typescript-estree': 5.49.0_typescript@4.9.4 + '@typescript-eslint/utils': 5.49.0_7uibuqfxkfaozanbtbziikiqje debug: 4.3.4 eslint: 8.32.0 tsutils: 3.21.0_typescript@4.9.4 @@ -3982,8 +3911,8 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@typescript-eslint/types/5.48.2: - resolution: {integrity: sha512-hE7dA77xxu7ByBc6KCzikgfRyBCTst6dZQpwaTy25iMYOnbNljDT4hjhrGEJJ0QoMjrfqrx+j1l1B9/LtKeuqA==} + /@typescript-eslint/types/5.49.0: + resolution: {integrity: sha512-7If46kusG+sSnEpu0yOz2xFv5nRz158nzEXnJFCGVEHWnuzolXKwrH5Bsf9zsNlOQkyZuk0BZKKoJQI+1JPBBg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true @@ -4008,8 +3937,8 @@ packages: - supports-color dev: true - /@typescript-eslint/typescript-estree/5.48.2_typescript@4.9.4: - resolution: {integrity: sha512-bibvD3z6ilnoVxUBFEgkO0k0aFvUc4Cttt0dAreEr+nrAHhWzkO83PEVVuieK3DqcgL6VAK5dkzK8XUVja5Zcg==} + /@typescript-eslint/typescript-estree/5.49.0_typescript@4.9.4: + resolution: {integrity: sha512-PBdx+V7deZT/3GjNYPVQv1Nc0U46dAHbIuOG8AZ3on3vuEKiPDwFE/lG1snN2eUB9IhF7EyF7K1hmTcLztNIsA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: typescript: '*' @@ -4017,8 +3946,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 5.48.2 - '@typescript-eslint/visitor-keys': 5.48.2 + '@typescript-eslint/types': 5.49.0 + '@typescript-eslint/visitor-keys': 5.49.0 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 @@ -4047,17 +3976,17 @@ packages: - typescript dev: true - /@typescript-eslint/utils/5.48.2_7uibuqfxkfaozanbtbziikiqje: - resolution: {integrity: sha512-2h18c0d7jgkw6tdKTlNaM7wyopbLRBiit8oAxoP89YnuBOzCZ8g8aBCaCqq7h208qUTroL7Whgzam7UY3HVLow==} + /@typescript-eslint/utils/5.49.0_7uibuqfxkfaozanbtbziikiqje: + resolution: {integrity: sha512-cPJue/4Si25FViIb74sHCLtM4nTSBXtLx1d3/QT6mirQ/c65bV8arBEebBJJizfq8W2YyMoPI/WWPFWitmNqnQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: '@types/json-schema': 7.0.11 '@types/semver': 7.3.12 - '@typescript-eslint/scope-manager': 5.48.2 - '@typescript-eslint/types': 5.48.2 - '@typescript-eslint/typescript-estree': 5.48.2_typescript@4.9.4 + '@typescript-eslint/scope-manager': 5.49.0 + '@typescript-eslint/types': 5.49.0 + '@typescript-eslint/typescript-estree': 5.49.0_typescript@4.9.4 eslint: 8.32.0 eslint-scope: 5.1.1 eslint-utils: 3.0.0_eslint@8.32.0 @@ -4075,11 +4004,11 @@ packages: eslint-visitor-keys: 3.3.0 dev: true - /@typescript-eslint/visitor-keys/5.48.2: - resolution: {integrity: sha512-z9njZLSkwmjFWUelGEwEbdf4NwKvfHxvGC0OcGN1Hp/XNDIcJ7D5DpPNPv6x6/mFvc1tQHsaWmpD/a4gOvvCJQ==} + /@typescript-eslint/visitor-keys/5.49.0: + resolution: {integrity: sha512-v9jBMjpNWyn8B6k/Mjt6VbUS4J1GvUlR4x3Y+ibnP1z7y7V4n0WRz+50DY6+Myj0UaXVSuUlHohO+eZ8IJEnkg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - '@typescript-eslint/types': 5.48.2 + '@typescript-eslint/types': 5.49.0 eslint-visitor-keys: 3.3.0 dev: true @@ -4362,8 +4291,8 @@ packages: engines: {node: '>=4'} dev: true - /axios/1.2.3: - resolution: {integrity: sha512-pdDkMYJeuXLZ6Xj/Q5J3Phpe+jbGdsSzlQaFVkMQzRUL05+6+tetX8TV3p4HrU4kzuO9bt+io/yGQxuyxA/xcw==} + /axios/1.2.4: + resolution: {integrity: sha512-lIQuCfBJvZB/Bv7+RWUqEJqNShGOVpk9v7P0ZWx5Ip0qY6u7JBAU6dzQPMLasU9vHL2uD8av/1FDJXj7n6c39w==} dependencies: follow-redirects: 1.15.1 form-data: 4.0.0 @@ -4400,8 +4329,8 @@ packages: - supports-color dev: true - /babel-jest/29.3.1_@babel+core@7.20.12: - resolution: {integrity: sha512-aard+xnMoxgjwV70t0L6wkW/3HQQtV+O0PEimxKgzNqCJnbYmroPojdP2tqKSOAt8QAKV/uSZU8851M7B5+fcA==} + /babel-jest/29.4.0_@babel+core@7.20.12: + resolution: {integrity: sha512-M61cGPg4JBashDvIzKoIV/y95mSF6x3ome7CMEaszUTHD4uo6dtC6Nln+fvRTspYNtwy8lDHl5lmoTBSNY/a+g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: '@babel/core': ^7.8.0 @@ -4410,10 +4339,10 @@ packages: optional: true dependencies: '@babel/core': 7.20.12 - '@jest/transform': 29.3.1 + '@jest/transform': 29.4.0 '@types/babel__core': 7.1.19 babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.2.0_@babel+core@7.20.12 + babel-preset-jest: 29.4.0_@babel+core@7.20.12 chalk: 4.1.2 graceful-fs: 4.2.10 slash: 3.0.0 @@ -4450,8 +4379,8 @@ packages: '@types/babel__traverse': 7.17.1 dev: true - /babel-plugin-jest-hoist/29.2.0: - resolution: {integrity: sha512-TnspP2WNiR3GLfCsUNHqeXw0RoQ2f9U5hQ5L3XFpwuO8htQmSrhh8qsB6vi5Yi8+kuynN1yjDjQsPfkebmB6ZA==} + /babel-plugin-jest-hoist/29.4.0: + resolution: {integrity: sha512-a/sZRLQJEmsmejQ2rPEUe35nO1+C9dc9O1gplH1SXmJxveQSRUYdBk8yGZG/VOUuZs1u2aHZJusEGoRMbhhwCg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@babel/template': 7.20.7 @@ -4577,8 +4506,8 @@ packages: babel-preset-current-node-syntax: 1.0.1_@babel+core@7.18.6 dev: true - /babel-preset-jest/29.2.0_@babel+core@7.20.12: - resolution: {integrity: sha512-z9JmMJppMxNv8N7fNRHvhMg9cvIkMxQBXgFkane3yKVEvEOP+kB50lk8DFRvF9PGqbyXxlmebKWhuDORO8RgdA==} + /babel-preset-jest/29.4.0_@babel+core@7.20.12: + resolution: {integrity: sha512-fUB9vZflUSM3dO/6M2TCAepTzvA4VkOvl67PjErcrQMGt9Eve7uazaeyCZ2th3UtI7ljpiBJES0F7A1vBRsLZA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: '@babel/core': ^7.0.0 @@ -4587,7 +4516,7 @@ packages: optional: true dependencies: '@babel/core': 7.20.12 - babel-plugin-jest-hoist: 29.2.0 + babel-plugin-jest-hoist: 29.4.0 babel-preset-current-node-syntax: 1.0.1_@babel+core@7.20.12 dev: true @@ -4777,6 +4706,11 @@ packages: supports-color: 7.2.0 dev: true + /chalk/5.2.0: + resolution: {integrity: sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: true + /char-regex/1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -5341,7 +5275,7 @@ packages: is-symbol: 1.0.4 dev: true - /esbuild-jest/0.5.0_esbuild@0.17.3: + /esbuild-jest/0.5.0_esbuild@0.17.4: resolution: {integrity: sha512-AMZZCdEpXfNVOIDvURlqYyHwC8qC1/BFjgsrOiSL1eyiIArVtHL8YAC83Shhn16cYYoAWEW17yZn0W/RJKJKHQ==} peerDependencies: esbuild: '>=0.8.50' @@ -5349,7 +5283,7 @@ packages: '@babel/core': 7.18.6 '@babel/plugin-transform-modules-commonjs': 7.18.6_@babel+core@7.18.6 babel-jest: 26.6.3_@babel+core@7.18.6 - esbuild: 0.17.3 + esbuild: 0.17.4 transitivePeerDependencies: - supports-color dev: true @@ -5384,34 +5318,34 @@ packages: '@esbuild/win32-x64': 0.16.17 dev: true - /esbuild/0.17.3: - resolution: {integrity: sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==} + /esbuild/0.17.4: + resolution: {integrity: sha512-zBn9MeCwT7W5F1a3lXClD61ip6vQM+H8Msb0w8zMT4ZKBpDg+rFAraNyWCDelB/2L6M3g6AXHPnsyvjMFnxtFw==} engines: {node: '>=12'} hasBin: true requiresBuild: true optionalDependencies: - '@esbuild/android-arm': 0.17.3 - '@esbuild/android-arm64': 0.17.3 - '@esbuild/android-x64': 0.17.3 - '@esbuild/darwin-arm64': 0.17.3 - '@esbuild/darwin-x64': 0.17.3 - '@esbuild/freebsd-arm64': 0.17.3 - '@esbuild/freebsd-x64': 0.17.3 - '@esbuild/linux-arm': 0.17.3 - '@esbuild/linux-arm64': 0.17.3 - '@esbuild/linux-ia32': 0.17.3 - '@esbuild/linux-loong64': 0.17.3 - '@esbuild/linux-mips64el': 0.17.3 - '@esbuild/linux-ppc64': 0.17.3 - '@esbuild/linux-riscv64': 0.17.3 - '@esbuild/linux-s390x': 0.17.3 - '@esbuild/linux-x64': 0.17.3 - '@esbuild/netbsd-x64': 0.17.3 - '@esbuild/openbsd-x64': 0.17.3 - '@esbuild/sunos-x64': 0.17.3 - '@esbuild/win32-arm64': 0.17.3 - '@esbuild/win32-ia32': 0.17.3 - '@esbuild/win32-x64': 0.17.3 + '@esbuild/android-arm': 0.17.4 + '@esbuild/android-arm64': 0.17.4 + '@esbuild/android-x64': 0.17.4 + '@esbuild/darwin-arm64': 0.17.4 + '@esbuild/darwin-x64': 0.17.4 + '@esbuild/freebsd-arm64': 0.17.4 + '@esbuild/freebsd-x64': 0.17.4 + '@esbuild/linux-arm': 0.17.4 + '@esbuild/linux-arm64': 0.17.4 + '@esbuild/linux-ia32': 0.17.4 + '@esbuild/linux-loong64': 0.17.4 + '@esbuild/linux-mips64el': 0.17.4 + '@esbuild/linux-ppc64': 0.17.4 + '@esbuild/linux-riscv64': 0.17.4 + '@esbuild/linux-s390x': 0.17.4 + '@esbuild/linux-x64': 0.17.4 + '@esbuild/netbsd-x64': 0.17.4 + '@esbuild/openbsd-x64': 0.17.4 + '@esbuild/sunos-x64': 0.17.4 + '@esbuild/win32-arm64': 0.17.4 + '@esbuild/win32-ia32': 0.17.4 + '@esbuild/win32-x64': 0.17.4 dev: true /escalade/3.1.1: @@ -5454,7 +5388,7 @@ packages: eslint: 8.32.0 dev: true - /eslint-config-react-app/7.0.1_tag7jkv7r4lhoczarutep4so5q: + /eslint-config-react-app/7.0.1_62kxqxvehyyeedrlmmzivzr77q: resolution: {integrity: sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -5467,14 +5401,14 @@ packages: '@babel/core': 7.18.6 '@babel/eslint-parser': 7.18.2_4vxy5hrmsc4hgeqdqlxhfbipxu '@rushstack/eslint-patch': 1.1.4 - '@typescript-eslint/eslint-plugin': 5.48.2_caon6io6stgpr7lz2rtbhekxqy - '@typescript-eslint/parser': 5.48.2_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/eslint-plugin': 5.49.0_iu322prlnwsygkcra5kbpy22si + '@typescript-eslint/parser': 5.49.0_7uibuqfxkfaozanbtbziikiqje babel-preset-react-app: 10.0.1 confusing-browser-globals: 1.0.11 eslint: 8.32.0 eslint-plugin-flowtype: 8.0.3_eslint@8.32.0 - eslint-plugin-import: 2.27.5_bzolr7xl6xcwr64wsu2tr4eimm - eslint-plugin-jest: 25.7.0_6cgxg5kejkpqx44p2w654f2rpi + eslint-plugin-import: 2.27.5_tto3jvfrcbe7ndbi56p7uxhaki + eslint-plugin-jest: 25.7.0_zcs5aryjwlqsbfhbanb4knkwyi eslint-plugin-jsx-a11y: 6.7.1_eslint@8.32.0 eslint-plugin-react: 7.32.1_eslint@8.32.0 eslint-plugin-react-hooks: 4.6.0_eslint@8.32.0 @@ -5513,7 +5447,7 @@ packages: debug: 4.3.4 enhanced-resolve: 5.10.0 eslint: 8.32.0 - eslint-plugin-import: 2.27.5_bzolr7xl6xcwr64wsu2tr4eimm + eslint-plugin-import: 2.27.5_tto3jvfrcbe7ndbi56p7uxhaki get-tsconfig: 4.2.0 globby: 13.1.2 is-core-module: 2.10.0 @@ -5523,7 +5457,7 @@ packages: - supports-color dev: true - /eslint-module-utils/2.7.4_ba2ykau6kcnaogk6czydxhup4m: + /eslint-module-utils/2.7.4_xoxtsypck35xtelm3fn5dkquvy: resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} engines: {node: '>=4'} peerDependencies: @@ -5544,7 +5478,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.48.2_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/parser': 5.49.0_7uibuqfxkfaozanbtbziikiqje debug: 3.2.7 eslint: 8.32.0 eslint-import-resolver-node: 0.3.7 @@ -5571,7 +5505,7 @@ packages: string-natural-compare: 3.0.1 dev: true - /eslint-plugin-import/2.27.5_bzolr7xl6xcwr64wsu2tr4eimm: + /eslint-plugin-import/2.27.5_tto3jvfrcbe7ndbi56p7uxhaki: resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==} engines: {node: '>=4'} peerDependencies: @@ -5581,7 +5515,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 5.48.2_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/parser': 5.49.0_7uibuqfxkfaozanbtbziikiqje array-includes: 3.1.6 array.prototype.flat: 1.3.1 array.prototype.flatmap: 1.3.1 @@ -5589,7 +5523,7 @@ packages: doctrine: 2.1.0 eslint: 8.32.0 eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.7.4_ba2ykau6kcnaogk6czydxhup4m + eslint-module-utils: 2.7.4_xoxtsypck35xtelm3fn5dkquvy has: 1.0.3 is-core-module: 2.11.0 is-glob: 4.0.3 @@ -5604,7 +5538,7 @@ packages: - supports-color dev: true - /eslint-plugin-jest/25.7.0_6cgxg5kejkpqx44p2w654f2rpi: + /eslint-plugin-jest/25.7.0_zcs5aryjwlqsbfhbanb4knkwyi: resolution: {integrity: sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} peerDependencies: @@ -5617,10 +5551,10 @@ packages: jest: optional: true dependencies: - '@typescript-eslint/eslint-plugin': 5.48.2_caon6io6stgpr7lz2rtbhekxqy + '@typescript-eslint/eslint-plugin': 5.49.0_iu322prlnwsygkcra5kbpy22si '@typescript-eslint/experimental-utils': 5.30.6_7uibuqfxkfaozanbtbziikiqje eslint: 8.32.0 - jest: 29.3.1_@types+node@18.11.18 + jest: 29.4.0_@types+node@18.11.18 transitivePeerDependencies: - supports-color - typescript @@ -5707,7 +5641,7 @@ packages: peerDependencies: eslint: ^7.5.0 || ^8.0.0 dependencies: - '@typescript-eslint/utils': 5.48.2_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/utils': 5.49.0_7uibuqfxkfaozanbtbziikiqje eslint: 8.32.0 transitivePeerDependencies: - supports-color @@ -5902,15 +5836,15 @@ packages: - supports-color dev: true - /expect/29.3.1: - resolution: {integrity: sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==} + /expect/29.4.0: + resolution: {integrity: sha512-pzaAwjBgLEVxBh6ZHiqb9Wv3JYuv6m8ntgtY7a48nS+2KbX0EJkPS3FQlKiTZNcqzqJHNyQsfjqN60w1hPUBfQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/expect-utils': 29.3.1 + '@jest/expect-utils': 29.4.0 jest-get-type: 29.2.0 - jest-matcher-utils: 29.3.1 - jest-message-util: 29.3.1 - jest-util: 29.3.1 + jest-matcher-utils: 29.4.0 + jest-message-util: 29.4.0 + jest-util: 29.4.0 dev: true /extend-shallow/2.0.1: @@ -6870,43 +6804,43 @@ packages: istanbul-lib-report: 3.0.0 dev: true - /jest-changed-files/29.2.0: - resolution: {integrity: sha512-qPVmLLyBmvF5HJrY7krDisx6Voi8DmlV3GZYX0aFNbaQsZeoz1hfxcCMbqDGuQCxU1dJy9eYc2xscE8QrCCYaA==} + /jest-changed-files/29.4.0: + resolution: {integrity: sha512-rnI1oPxgFghoz32Y8eZsGJMjW54UlqT17ycQeCEktcxxwqqKdlj9afl8LNeO0Pbu+h2JQHThQP0BzS67eTRx4w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: execa: 5.1.1 p-limit: 3.1.0 dev: true - /jest-circus/29.3.1: - resolution: {integrity: sha512-wpr26sEvwb3qQQbdlmei+gzp6yoSSoSL6GsLPxnuayZSMrSd5Ka7IjAvatpIernBvT2+Ic6RLTg+jSebScmasg==} + /jest-circus/29.4.0: + resolution: {integrity: sha512-/pFBaCeLzCavRWyz14JwFgpZgPpEZdS6nPnREhczbHl2wy2UezvYcVp5akVFfUmBaA4ThAUp0I8cpgkbuNOm3g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/environment': 29.3.1 - '@jest/expect': 29.3.1 - '@jest/test-result': 29.3.1 - '@jest/types': 29.3.1 + '@jest/environment': 29.4.0 + '@jest/expect': 29.4.0 + '@jest/test-result': 29.4.0 + '@jest/types': 29.4.0 '@types/node': 18.11.18 chalk: 4.1.2 co: 4.6.0 dedent: 0.7.0 is-generator-fn: 2.1.0 - jest-each: 29.3.1 - jest-matcher-utils: 29.3.1 - jest-message-util: 29.3.1 - jest-runtime: 29.3.1 - jest-snapshot: 29.3.1 - jest-util: 29.3.1 + jest-each: 29.4.0 + jest-matcher-utils: 29.4.0 + jest-message-util: 29.4.0 + jest-runtime: 29.4.0 + jest-snapshot: 29.4.0 + jest-util: 29.4.0 p-limit: 3.1.0 - pretty-format: 29.3.1 + pretty-format: 29.4.0 slash: 3.0.0 stack-utils: 2.0.5 transitivePeerDependencies: - supports-color dev: true - /jest-cli/29.3.1_@types+node@18.11.18: - resolution: {integrity: sha512-TO/ewvwyvPOiBBuWZ0gm04z3WWP8TIK8acgPzE4IxgsLKQgb377NYGrQLc3Wl/7ndWzIH2CDNNsUjGxwLL43VQ==} + /jest-cli/29.4.0_@types+node@18.11.18: + resolution: {integrity: sha512-YUkICcxjUd864VOzbfQEi2qd2hIIOd9bRF7LJUNyhWb3Khh3YKrbY0LWwoZZ4WkvukiNdvQu0Z4s6zLsY4hYfg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true peerDependencies: @@ -6915,16 +6849,16 @@ packages: node-notifier: optional: true dependencies: - '@jest/core': 29.3.1 - '@jest/test-result': 29.3.1 - '@jest/types': 29.3.1 + '@jest/core': 29.4.0 + '@jest/test-result': 29.4.0 + '@jest/types': 29.4.0 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.10 import-local: 3.1.0 - jest-config: 29.3.1_@types+node@18.11.18 - jest-util: 29.3.1 - jest-validate: 29.3.1 + jest-config: 29.4.0_@types+node@18.11.18 + jest-util: 29.4.0 + jest-validate: 29.4.0 prompts: 2.4.2 yargs: 17.5.1 transitivePeerDependencies: @@ -6933,8 +6867,8 @@ packages: - ts-node dev: true - /jest-config/29.3.1_@types+node@18.11.18: - resolution: {integrity: sha512-y0tFHdj2WnTEhxmGUK1T7fgLen7YK4RtfvpLFBXfQkh2eMJAQq24Vx9472lvn5wg0MAO6B+iPfJfzdR9hJYalg==} + /jest-config/29.4.0_@types+node@18.11.18: + resolution: {integrity: sha512-jtgd72nN4Mob4Oego3N/pLRVfR2ui1hv+yO6xR/SUi5G7NtZ/grr95BJ1qRSDYZshuA0Jw57fnttZHZKb04+CA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: '@types/node': '*' @@ -6946,40 +6880,40 @@ packages: optional: true dependencies: '@babel/core': 7.20.12 - '@jest/test-sequencer': 29.3.1 - '@jest/types': 29.3.1 + '@jest/test-sequencer': 29.4.0 + '@jest/types': 29.4.0 '@types/node': 18.11.18 - babel-jest: 29.3.1_@babel+core@7.20.12 + babel-jest: 29.4.0_@babel+core@7.20.12 chalk: 4.1.2 ci-info: 3.3.2 deepmerge: 4.2.2 glob: 7.2.3 graceful-fs: 4.2.10 - jest-circus: 29.3.1 - jest-environment-node: 29.3.1 + jest-circus: 29.4.0 + jest-environment-node: 29.4.0 jest-get-type: 29.2.0 jest-regex-util: 29.2.0 - jest-resolve: 29.3.1 - jest-runner: 29.3.1 - jest-util: 29.3.1 - jest-validate: 29.3.1 + jest-resolve: 29.4.0 + jest-runner: 29.4.0 + jest-util: 29.4.0 + jest-validate: 29.4.0 micromatch: 4.0.5 parse-json: 5.2.0 - pretty-format: 29.3.1 + pretty-format: 29.4.0 slash: 3.0.0 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color dev: true - /jest-diff/29.3.1: - resolution: {integrity: sha512-vU8vyiO7568tmin2lA3r2DP8oRvzhvRcD4DjpXc6uGveQodyk7CKLhQlCSiwgx3g0pFaE88/KLZ0yaTWMc4Uiw==} + /jest-diff/29.4.0: + resolution: {integrity: sha512-s8KNvFx8YgdQ4fn2YLDQ7N6kmVOP68dUDVJrCHNsTc3UM5jcmyyFeYKL8EPWBQbJ0o0VvDGbWp8oYQ1nsnqnWw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: chalk: 4.1.2 diff-sequences: 29.3.1 jest-get-type: 29.2.0 - pretty-format: 29.3.1 + pretty-format: 29.4.0 dev: true /jest-docblock/29.2.0: @@ -6989,19 +6923,19 @@ packages: detect-newline: 3.1.0 dev: true - /jest-each/29.3.1: - resolution: {integrity: sha512-qrZH7PmFB9rEzCSl00BWjZYuS1BSOH8lLuC0azQE9lQrAx3PWGKHTDudQiOSwIy5dGAJh7KA0ScYlCP7JxvFYA==} + /jest-each/29.4.0: + resolution: {integrity: sha512-LTOvB8JDVFjrwXItyQiyLuDYy5PMApGLLzbfIYR79QLpeohS0bcS6j2HjlWuRGSM8QQQyp+ico59Blv+Jx3fMw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/types': 29.3.1 + '@jest/types': 29.4.0 chalk: 4.1.2 jest-get-type: 29.2.0 - jest-util: 29.3.1 - pretty-format: 29.3.1 + jest-util: 29.4.0 + pretty-format: 29.4.0 dev: true - /jest-environment-jsdom/29.3.1: - resolution: {integrity: sha512-G46nKgiez2Gy4zvYNhayfMEAFlVHhWfncqvqS6yCd0i+a4NsSUD2WtrKSaYQrYiLQaupHXxCRi8xxVL2M9PbhA==} + /jest-environment-jsdom/29.4.0: + resolution: {integrity: sha512-z1tB/qtReousDnU695K38ZzoR6B3dRXazwgyhTHzMviSC2T3KmVy0T722fZxR2q3x/Jvv85JxU/2xs8kwX394w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: canvas: ^2.5.0 @@ -7009,13 +6943,13 @@ packages: canvas: optional: true dependencies: - '@jest/environment': 29.3.1 - '@jest/fake-timers': 29.3.1 - '@jest/types': 29.3.1 + '@jest/environment': 29.4.0 + '@jest/fake-timers': 29.4.0 + '@jest/types': 29.4.0 '@types/jsdom': 20.0.0 '@types/node': 18.11.18 - jest-mock: 29.3.1 - jest-util: 29.3.1 + jest-mock: 29.4.0 + jest-util: 29.4.0 jsdom: 20.0.0 transitivePeerDependencies: - bufferutil @@ -7023,16 +6957,16 @@ packages: - utf-8-validate dev: true - /jest-environment-node/29.3.1: - resolution: {integrity: sha512-xm2THL18Xf5sIHoU7OThBPtuH6Lerd+Y1NLYiZJlkE3hbE+7N7r8uvHIl/FkZ5ymKXJe/11SQuf3fv4v6rUMag==} + /jest-environment-node/29.4.0: + resolution: {integrity: sha512-WVveE3fYSH6FhDtZdvXhFKeLsDRItlQgnij+HQv6ZKxTdT1DB5O0sHXKCEC3K5mHraMs1Kzn4ch9jXC7H4L4wA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/environment': 29.3.1 - '@jest/fake-timers': 29.3.1 - '@jest/types': 29.3.1 + '@jest/environment': 29.4.0 + '@jest/fake-timers': 29.4.0 + '@jest/types': 29.4.0 '@types/node': 18.11.18 - jest-mock: 29.3.1 - jest-util: 29.3.1 + jest-mock: 29.4.0 + jest-util: 29.4.0 dev: true /jest-get-type/29.2.0: @@ -7063,68 +6997,68 @@ packages: - supports-color dev: true - /jest-haste-map/29.3.1: - resolution: {integrity: sha512-/FFtvoG1xjbbPXQLFef+WSU4yrc0fc0Dds6aRPBojUid7qlPqZvxdUBA03HW0fnVHXVCnCdkuoghYItKNzc/0A==} + /jest-haste-map/29.4.0: + resolution: {integrity: sha512-m/pIEfoK0HoJz4c9bkgS5F9CXN2AM22eaSmUcmqTpadRlNVBOJE2CwkgaUzbrNn5MuAqTV1IPVYwWwjHNnk8eA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/types': 29.3.1 + '@jest/types': 29.4.0 '@types/graceful-fs': 4.1.5 '@types/node': 18.11.18 anymatch: 3.1.2 fb-watchman: 2.0.1 graceful-fs: 4.2.10 jest-regex-util: 29.2.0 - jest-util: 29.3.1 - jest-worker: 29.3.1 + jest-util: 29.4.0 + jest-worker: 29.4.0 micromatch: 4.0.5 walker: 1.0.8 optionalDependencies: fsevents: 2.3.2 dev: true - /jest-leak-detector/29.3.1: - resolution: {integrity: sha512-3DA/VVXj4zFOPagGkuqHnSQf1GZBmmlagpguxEERO6Pla2g84Q1MaVIB3YMxgUaFIaYag8ZnTyQgiZ35YEqAQA==} + /jest-leak-detector/29.4.0: + resolution: {integrity: sha512-fEGHS6ijzgSv5exABkCecMHNmyHcV52+l39ZsxuwfxmQMp43KBWJn2/Fwg8/l4jTI9uOY9jv8z1dXGgL0PHFjA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: jest-get-type: 29.2.0 - pretty-format: 29.3.1 + pretty-format: 29.4.0 dev: true - /jest-matcher-utils/29.3.1: - resolution: {integrity: sha512-fkRMZUAScup3txIKfMe3AIZZmPEjWEdsPJFK3AIy5qRohWqQFg1qrmKfYXR9qEkNc7OdAu2N4KPHibEmy4HPeQ==} + /jest-matcher-utils/29.4.0: + resolution: {integrity: sha512-pU4OjBn96rDdRIaPUImbPiO2ETyRVzkA1EZVu9AxBDv/XPDJ7JWfkb6IiDT5jwgicaPHMrB/fhVa6qjG6potfA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: chalk: 4.1.2 - jest-diff: 29.3.1 + jest-diff: 29.4.0 jest-get-type: 29.2.0 - pretty-format: 29.3.1 + pretty-format: 29.4.0 dev: true - /jest-message-util/29.3.1: - resolution: {integrity: sha512-lMJTbgNcDm5z+6KDxWtqOFWlGQxD6XaYwBqHR8kmpkP+WWWG90I35kdtQHY67Ay5CSuydkTBbJG+tH9JShFCyA==} + /jest-message-util/29.4.0: + resolution: {integrity: sha512-0FvobqymmhE9pDEifvIcni9GeoKLol8eZspzH5u41g1wxYtLS60a9joT95dzzoCgrKRidNz64eaAXyzaULV8og==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@babel/code-frame': 7.18.6 - '@jest/types': 29.3.1 + '@jest/types': 29.4.0 '@types/stack-utils': 2.0.1 chalk: 4.1.2 graceful-fs: 4.2.10 micromatch: 4.0.5 - pretty-format: 29.3.1 + pretty-format: 29.4.0 slash: 3.0.0 stack-utils: 2.0.5 dev: true - /jest-mock/29.3.1: - resolution: {integrity: sha512-H8/qFDtDVMFvFP4X8NuOT3XRDzOUTz+FeACjufHzsOIBAxivLqkB1PoLCaJx9iPPQ8dZThHPp/G3WRWyMgA3JA==} + /jest-mock/29.4.0: + resolution: {integrity: sha512-+ShT5i+hcu/OFQRV0f/V/YtwpdFcHg64JZ9A8b40JueP+X9HNrZAYGdkupGIzsUK8AucecxCt4wKauMchxubLQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/types': 29.3.1 + '@jest/types': 29.4.0 '@types/node': 18.11.18 - jest-util: 29.3.1 + jest-util: 29.4.0 dev: true - /jest-pnp-resolver/1.2.2_jest-resolve@29.3.1: + /jest-pnp-resolver/1.2.2_jest-resolve@29.4.0: resolution: {integrity: sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==} engines: {node: '>=6'} peerDependencies: @@ -7133,7 +7067,7 @@ packages: jest-resolve: optional: true dependencies: - jest-resolve: 29.3.1 + jest-resolve: 29.4.0 dev: true /jest-regex-util/26.0.0: @@ -7146,84 +7080,85 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true - /jest-resolve-dependencies/29.3.1: - resolution: {integrity: sha512-Vk0cYq0byRw2WluNmNWGqPeRnZ3p3hHmjJMp2dyyZeYIfiBskwq4rpiuGFR6QGAdbj58WC7HN4hQHjf2mpvrLA==} + /jest-resolve-dependencies/29.4.0: + resolution: {integrity: sha512-hxfC84trREyULSj1Cm+fMjnudrrI2dVQ04COjZRcjCZ97boJlPtfJ+qrl/pN7YXS2fnu3wTHEc3LO094pngL6A==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: jest-regex-util: 29.2.0 - jest-snapshot: 29.3.1 + jest-snapshot: 29.4.0 transitivePeerDependencies: - supports-color dev: true - /jest-resolve/29.3.1: - resolution: {integrity: sha512-amXJgH/Ng712w3Uz5gqzFBBjxV8WFLSmNjoreBGMqxgCz5cH7swmBZzgBaCIOsvb0NbpJ0vgaSFdJqMdT+rADw==} + /jest-resolve/29.4.0: + resolution: {integrity: sha512-g7k7l53T+uC9Dp1mbHyDNkcCt0PMku6Wcfpr1kcMLwOHmM3vucKjSM5+DSa1r4vlDZojh8XH039J3z4FKmtTSw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: chalk: 4.1.2 graceful-fs: 4.2.10 - jest-haste-map: 29.3.1 - jest-pnp-resolver: 1.2.2_jest-resolve@29.3.1 - jest-util: 29.3.1 - jest-validate: 29.3.1 + jest-haste-map: 29.4.0 + jest-pnp-resolver: 1.2.2_jest-resolve@29.4.0 + jest-util: 29.4.0 + jest-validate: 29.4.0 resolve: 1.22.1 - resolve.exports: 1.1.0 + resolve.exports: 2.0.0 slash: 3.0.0 dev: true - /jest-runner/29.3.1: - resolution: {integrity: sha512-oFvcwRNrKMtE6u9+AQPMATxFcTySyKfLhvso7Sdk/rNpbhg4g2GAGCopiInk1OP4q6gz3n6MajW4+fnHWlU3bA==} + /jest-runner/29.4.0: + resolution: {integrity: sha512-4zpcv0NOiJleqT0NAs8YcVbK8MhVRc58CBBn9b0Exc8VPU9GKI+DbzDUZqJYdkJhJSZFy2862l/F6hAqIow1hg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/console': 29.3.1 - '@jest/environment': 29.3.1 - '@jest/test-result': 29.3.1 - '@jest/transform': 29.3.1 - '@jest/types': 29.3.1 + '@jest/console': 29.4.0 + '@jest/environment': 29.4.0 + '@jest/test-result': 29.4.0 + '@jest/transform': 29.4.0 + '@jest/types': 29.4.0 '@types/node': 18.11.18 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.10 jest-docblock: 29.2.0 - jest-environment-node: 29.3.1 - jest-haste-map: 29.3.1 - jest-leak-detector: 29.3.1 - jest-message-util: 29.3.1 - jest-resolve: 29.3.1 - jest-runtime: 29.3.1 - jest-util: 29.3.1 - jest-watcher: 29.3.1 - jest-worker: 29.3.1 + jest-environment-node: 29.4.0 + jest-haste-map: 29.4.0 + jest-leak-detector: 29.4.0 + jest-message-util: 29.4.0 + jest-resolve: 29.4.0 + jest-runtime: 29.4.0 + jest-util: 29.4.0 + jest-watcher: 29.4.0 + jest-worker: 29.4.0 p-limit: 3.1.0 source-map-support: 0.5.13 transitivePeerDependencies: - supports-color dev: true - /jest-runtime/29.3.1: - resolution: {integrity: sha512-jLzkIxIqXwBEOZx7wx9OO9sxoZmgT2NhmQKzHQm1xwR1kNW/dn0OjxR424VwHHf1SPN6Qwlb5pp1oGCeFTQ62A==} + /jest-runtime/29.4.0: + resolution: {integrity: sha512-2zumwaGXsIuSF92Ui5Pn5hZV9r7AHMclfBLikrXSq87/lHea9anQ+mC+Cjz/DYTbf/JMjlK1sjZRh8K3yYNvWg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/environment': 29.3.1 - '@jest/fake-timers': 29.3.1 - '@jest/globals': 29.3.1 + '@jest/environment': 29.4.0 + '@jest/fake-timers': 29.4.0 + '@jest/globals': 29.4.0 '@jest/source-map': 29.2.0 - '@jest/test-result': 29.3.1 - '@jest/transform': 29.3.1 - '@jest/types': 29.3.1 + '@jest/test-result': 29.4.0 + '@jest/transform': 29.4.0 + '@jest/types': 29.4.0 '@types/node': 18.11.18 chalk: 4.1.2 cjs-module-lexer: 1.2.2 collect-v8-coverage: 1.0.1 glob: 7.2.3 graceful-fs: 4.2.10 - jest-haste-map: 29.3.1 - jest-message-util: 29.3.1 - jest-mock: 29.3.1 + jest-haste-map: 29.4.0 + jest-message-util: 29.4.0 + jest-mock: 29.4.0 jest-regex-util: 29.2.0 - jest-resolve: 29.3.1 - jest-snapshot: 29.3.1 - jest-util: 29.3.1 + jest-resolve: 29.4.0 + jest-snapshot: 29.4.0 + jest-util: 29.4.0 + semver: 7.3.8 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: @@ -7238,8 +7173,8 @@ packages: graceful-fs: 4.2.10 dev: true - /jest-snapshot/29.3.1: - resolution: {integrity: sha512-+3JOc+s28upYLI2OJM4PWRGK9AgpsMs/ekNryUV0yMBClT9B1DF2u2qay8YxcQd338PPYSFNb0lsar1B49sLDA==} + /jest-snapshot/29.4.0: + resolution: {integrity: sha512-UnK3MhdEWrQ2J6MnlKe51tvN5FjRUBQnO4m1LPlDx61or3w9+cP/U0x9eicutgunu/QzE4WC82jj6CiGIAFYzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@babel/core': 7.20.12 @@ -7248,23 +7183,23 @@ packages: '@babel/plugin-syntax-typescript': 7.18.6_@babel+core@7.20.12 '@babel/traverse': 7.20.12 '@babel/types': 7.20.7 - '@jest/expect-utils': 29.3.1 - '@jest/transform': 29.3.1 - '@jest/types': 29.3.1 + '@jest/expect-utils': 29.4.0 + '@jest/transform': 29.4.0 + '@jest/types': 29.4.0 '@types/babel__traverse': 7.17.1 '@types/prettier': 2.6.3 babel-preset-current-node-syntax: 1.0.1_@babel+core@7.20.12 chalk: 4.1.2 - expect: 29.3.1 + expect: 29.4.0 graceful-fs: 4.2.10 - jest-diff: 29.3.1 + jest-diff: 29.4.0 jest-get-type: 29.2.0 - jest-haste-map: 29.3.1 - jest-matcher-utils: 29.3.1 - jest-message-util: 29.3.1 - jest-util: 29.3.1 + jest-haste-map: 29.4.0 + jest-matcher-utils: 29.4.0 + jest-message-util: 29.4.0 + jest-util: 29.4.0 natural-compare: 1.4.0 - pretty-format: 29.3.1 + pretty-format: 29.4.0 semver: 7.3.8 transitivePeerDependencies: - supports-color @@ -7286,11 +7221,11 @@ packages: micromatch: 4.0.5 dev: true - /jest-util/29.3.1: - resolution: {integrity: sha512-7YOVZaiX7RJLv76ZfHt4nbNEzzTRiMW/IiOG7ZOKmTXmoGBxUDefgMAxQubu6WPVqP5zSzAdZG0FfLcC7HOIFQ==} + /jest-util/29.4.0: + resolution: {integrity: sha512-lCCwlze7UEV8TpR9ArS8w0cTbcMry5tlBkg7QSc5og5kNyV59dnY2aKHu5fY2k5aDJMQpCUGpvL2w6ZU44lveA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/types': 29.3.1 + '@jest/types': 29.4.0 '@types/node': 18.11.18 chalk: 4.1.2 ci-info: 3.3.2 @@ -7298,45 +7233,45 @@ packages: picomatch: 2.3.1 dev: true - /jest-validate/29.3.1: - resolution: {integrity: sha512-N9Lr3oYR2Mpzuelp1F8negJR3YE+L1ebk1rYA5qYo9TTY3f9OWdptLoNSPP9itOCBIRBqjt/S5XHlzYglLN67g==} + /jest-validate/29.4.0: + resolution: {integrity: sha512-EXS7u594nX3aAPBnARxBdJ1eZ1cByV6MWrK0Qpt9lt/BcY0p0yYGp/EGJ8GhdLDQh+RFf8qMt2wzbbVzpj5+Vg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/types': 29.3.1 + '@jest/types': 29.4.0 camelcase: 6.3.0 chalk: 4.1.2 jest-get-type: 29.2.0 leven: 3.1.0 - pretty-format: 29.3.1 + pretty-format: 29.4.0 dev: true - /jest-watch-typeahead/2.2.1_jest@29.3.1: - resolution: {integrity: sha512-jYpYmUnTzysmVnwq49TAxlmtOAwp8QIqvZyoofQFn8fiWhEDZj33ZXzg3JA4nGnzWFm1hbWf3ADpteUokvXgFA==} + /jest-watch-typeahead/2.2.2_jest@29.4.0: + resolution: {integrity: sha512-+QgOFW4o5Xlgd6jGS5X37i08tuuXNW8X0CV9WNFi+3n8ExCIP+E1melYhvYLjv5fE6D0yyzk74vsSO8I6GqtvQ==} engines: {node: ^14.17.0 || ^16.10.0 || >=18.0.0} peerDependencies: jest: ^27.0.0 || ^28.0.0 || ^29.0.0 dependencies: ansi-escapes: 6.0.0 - chalk: 4.1.2 - jest: 29.3.1_@types+node@18.11.18 + chalk: 5.2.0 + jest: 29.4.0_@types+node@18.11.18 jest-regex-util: 29.2.0 - jest-watcher: 29.3.1 + jest-watcher: 29.4.0 slash: 5.0.0 string-length: 5.0.1 strip-ansi: 7.0.1 dev: true - /jest-watcher/29.3.1: - resolution: {integrity: sha512-RspXG2BQFDsZSRKGCT/NiNa8RkQ1iKAjrO0//soTMWx/QUt+OcxMqMSBxz23PYGqUuWm2+m2mNNsmj0eIoOaFg==} + /jest-watcher/29.4.0: + resolution: {integrity: sha512-PnnfLygNKelWOJwpAYlcsQjB+OxRRdckD0qiGmYng4Hkz1ZwK3jvCaJJYiywz2msQn4rBNLdriasJtv7YpWHpA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/test-result': 29.3.1 - '@jest/types': 29.3.1 + '@jest/test-result': 29.4.0 + '@jest/types': 29.4.0 '@types/node': 18.11.18 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 - jest-util: 29.3.1 + jest-util: 29.4.0 string-length: 4.0.2 dev: true @@ -7349,18 +7284,18 @@ packages: supports-color: 7.2.0 dev: true - /jest-worker/29.3.1: - resolution: {integrity: sha512-lY4AnnmsEWeiXirAIA0c9SDPbuCBq8IYuDVL8PMm0MZ2PEs2yPvRA/J64QBXuZp7CYKrDM/rmNrc9/i3KJQncw==} + /jest-worker/29.4.0: + resolution: {integrity: sha512-dICMQ+Q4W0QVMsaQzWlA1FVQhKNz7QcDCOGtbk1GCAd0Lai+wdkQvfmQwL4MjGumineh1xz+6M5oMj3rfWS02A==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@types/node': 18.11.18 - jest-util: 29.3.1 + jest-util: 29.4.0 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true - /jest/29.3.1_@types+node@18.11.18: - resolution: {integrity: sha512-6iWfL5DTT0Np6UYs/y5Niu7WIfNv/wRTtN5RSXt2DIEft3dx3zPuw/3WJQBCJfmEzvDiEKwoqMbGD9n49+qLSA==} + /jest/29.4.0_@types+node@18.11.18: + resolution: {integrity: sha512-Zfd4UzNxPkSoHRBkg225rBjQNa6pVqbh20MGniAzwaOzYLd+pQUcAwH+WPxSXxKFs+QWYfPYIq9hIVSmdVQmPA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true peerDependencies: @@ -7369,10 +7304,10 @@ packages: node-notifier: optional: true dependencies: - '@jest/core': 29.3.1 - '@jest/types': 29.3.1 + '@jest/core': 29.4.0 + '@jest/types': 29.4.0 import-local: 3.1.0 - jest-cli: 29.3.1_@types+node@18.11.18 + jest-cli: 29.4.0_@types+node@18.11.18 transitivePeerDependencies: - '@types/node' - supports-color @@ -8287,11 +8222,11 @@ packages: react-is: 17.0.2 dev: true - /pretty-format/29.3.1: - resolution: {integrity: sha512-FyLnmb1cYJV8biEIiRyzRFvs2lry7PPIvOqKVe1GCUEYg4YGmlx1qG9EJNMxArYm7piII4qb8UV1Pncq5dxmcg==} + /pretty-format/29.4.0: + resolution: {integrity: sha512-J+EVUPXIBHCdWAbvGBwXs0mk3ljGppoh/076g1S8qYS8nVG4u/yrhMvyTFHYYYKWnDdgRLExx0vA7pzxVGdlNw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@jest/schemas': 29.0.0 + '@jest/schemas': 29.4.0 ansi-styles: 5.2.0 react-is: 18.2.0 dev: true @@ -8636,8 +8571,8 @@ packages: deprecated: https://github.com/lydell/resolve-url#deprecated dev: true - /resolve.exports/1.1.0: - resolution: {integrity: sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==} + /resolve.exports/2.0.0: + resolution: {integrity: sha512-6K/gDlqgQscOlg9fSRpWstA8sYe8rbELsSTNpx+3kTrsVCzvSl0zIvRErM7fdl9ERWDsKnrLnwB+Ne89918XOg==} engines: {node: '>=10'} dev: true @@ -9696,9 +9631,9 @@ packages: typedarray-to-buffer: 3.1.5 dev: true - /write-file-atomic/4.0.1: - resolution: {integrity: sha512-nSKUxgAbyioruk6hU87QzVbY279oYT6uiwgDoujth2ju4mJ+TZau7SQBhtbTmUyuNYTuXnSyRn66FV0+eCgcrQ==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16} + /write-file-atomic/5.0.0: + resolution: {integrity: sha512-R7NYMnHSlV42K54lwY9lvW6MnSm1HSJqZL3xiSgi9E7//FYaI74r2G0rd+/X6VAMkHEdzxQaU5HUOXWUz5kA/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} dependencies: imurmurhash: 0.1.4 signal-exit: 3.0.7 diff --git a/web/src/components/Brand.tsx b/web/src/components/Brand.tsx index 86a9326e8..18712c362 100644 --- a/web/src/components/Brand.tsx +++ b/web/src/components/Brand.tsx @@ -1,10 +1,13 @@ -import React from "react"; +import React, { Fragment } from "react"; -import { Grid, Link, Theme } from "@mui/material"; +import { Divider, Grid, Link, Theme } from "@mui/material"; import { grey } from "@mui/material/colors"; import makeStyles from "@mui/styles/makeStyles"; import { useTranslation } from "react-i18next"; +import PrivacyPolicyLink from "@components/PrivacyPolicyLink"; +import { getPrivacyPolicyEnabled } from "@utils/Configuration"; + export interface Props {} const url = "https://www.authelia.com"; @@ -12,12 +15,23 @@ const url = "https://www.authelia.com"; const Brand = function (props: Props) { const { t: translate } = useTranslation(); const styles = useStyles(); + const privacyEnabled = getPrivacyPolicyEnabled(); return ( - - - {translate("Powered by")} Authelia - + + + + {translate("Powered by")} Authelia + + + {privacyEnabled ? ( + + + + + + + ) : null} ); }; @@ -25,7 +39,7 @@ const Brand = function (props: Props) { export default Brand; const useStyles = makeStyles((theme: Theme) => ({ - poweredBy: { + links: { fontSize: "0.7em", color: grey[500], }, diff --git a/web/src/components/PrivacyPolicyDrawer.tsx b/web/src/components/PrivacyPolicyDrawer.tsx new file mode 100644 index 000000000..dcdb363b5 --- /dev/null +++ b/web/src/components/PrivacyPolicyDrawer.tsx @@ -0,0 +1,54 @@ +import { Button, Drawer, DrawerProps, Grid, Typography } from "@mui/material"; +import { Trans, useTranslation } from "react-i18next"; + +import PrivacyPolicyLink from "@components/PrivacyPolicyLink"; +import { usePersistentStorageValue } from "@hooks/PersistentStorage"; +import { getPrivacyPolicyEnabled, getPrivacyPolicyRequireAccept } from "@utils/Configuration"; + +const PrivacyPolicyDrawer = function (props: DrawerProps) { + const privacyEnabled = getPrivacyPolicyEnabled(); + const privacyRequireAccept = getPrivacyPolicyRequireAccept(); + const [accepted, setAccepted] = usePersistentStorageValue("privacy-policy-accepted", false); + const { t: translate } = useTranslation(); + + return privacyEnabled && privacyRequireAccept && !accepted ? ( + + + + + + {translate("Privacy Policy")} + + + + + + ]} + />{" "} + Authelia. + + + + + + + + ) : null; +}; + +export default PrivacyPolicyDrawer; diff --git a/web/src/components/PrivacyPolicyLink.tsx b/web/src/components/PrivacyPolicyLink.tsx new file mode 100644 index 000000000..c9b82fc77 --- /dev/null +++ b/web/src/components/PrivacyPolicyLink.tsx @@ -0,0 +1,22 @@ +import React, { Fragment } from "react"; + +import { Link, LinkProps } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { getPrivacyPolicyURL } from "@utils/Configuration"; + +const PrivacyPolicyLink = function (props: LinkProps) { + const hrefPrivacyPolicy = getPrivacyPolicyURL(); + + const { t: translate } = useTranslation(); + + return ( + + + {translate("Privacy Policy")} + + + ); +}; + +export default PrivacyPolicyLink; diff --git a/web/src/hooks/PersistentStorage.ts b/web/src/hooks/PersistentStorage.ts new file mode 100644 index 000000000..ce129a271 --- /dev/null +++ b/web/src/hooks/PersistentStorage.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; + +interface PersistentStorage { + getItem(key: string): string | null; + setItem(key: string, value: any): void; +} + +class LocalStorage implements PersistentStorage { + getItem(key: string) { + const item = localStorage.getItem(key); + + if (item === null) return undefined; + + if (item === "null") return null; + if (item === "undefined") return undefined; + + try { + return JSON.parse(item); + } catch {} + + return item; + } + setItem(key: string, value: any) { + if (value === undefined) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(value)); + } + } +} + +class MockStorage implements PersistentStorage { + getItem() { + return null; + } + setItem() {} +} + +const persistentStorage = window?.localStorage ? new LocalStorage() : new MockStorage(); + +export function usePersistentStorageValue(key: string, initialValue?: T) { + const [value, setValue] = useState(() => { + const valueFromStorage = persistentStorage.getItem(key); + + if (typeof initialValue === "object" && !Array.isArray(initialValue) && initialValue !== null) { + return { + ...initialValue, + ...valueFromStorage, + }; + } + + return valueFromStorage || initialValue; + }); + + useEffect(() => { + persistentStorage.setItem(key, value); + }, [key, value]); + + return [value, setValue] as const; +} diff --git a/web/src/layouts/LoginLayout.tsx b/web/src/layouts/LoginLayout.tsx index c293f4d1b..a6bd2eb45 100644 --- a/web/src/layouts/LoginLayout.tsx +++ b/web/src/layouts/LoginLayout.tsx @@ -8,6 +8,7 @@ import { useNavigate } from "react-router-dom"; import { ReactComponent as UserSvg } from "@assets/images/user.svg"; import Brand from "@components/Brand"; +import PrivacyPolicyDrawer from "@components/PrivacyPolicyDrawer"; import TypographyWithTooltip from "@components/TypographyWithTootip"; import { SettingsRoute } from "@constants/Routes"; import { getLogoOverride } from "@utils/Configuration"; @@ -26,12 +27,14 @@ export interface Props { const LoginLayout = function (props: Props) { const navigate = useNavigate(); const styles = useStyles(); + const { t: translate } = useTranslation(); + const logo = getLogoOverride() ? ( Logo ) : ( ); - const { t: translate } = useTranslation(); + useEffect(() => { document.title = `${translate("Login")} - Authelia`; }, [translate]); @@ -62,9 +65,9 @@ const LoginLayout = function (props: Props) { : null} + ); diff --git a/web/src/setupTests.js b/web/src/setupTests.js index 1c5931ad7..e6067ae8d 100644 --- a/web/src/setupTests.js +++ b/web/src/setupTests.js @@ -5,4 +5,6 @@ document.body.setAttribute("data-duoselfenrollment", "true"); document.body.setAttribute("data-rememberme", "true"); document.body.setAttribute("data-resetpassword", "true"); document.body.setAttribute("data-resetpasswordcustomurl", ""); +document.body.setAttribute("data-privacypolicyurl", ""); +document.body.setAttribute("data-privacypolicyaccept", "false"); document.body.setAttribute("data-theme", "light"); diff --git a/web/src/utils/Configuration.ts b/web/src/utils/Configuration.ts index 25ff419d3..dcae6f690 100644 --- a/web/src/utils/Configuration.ts +++ b/web/src/utils/Configuration.ts @@ -27,6 +27,18 @@ export function getResetPasswordCustomURL() { return getEmbeddedVariable("resetpasswordcustomurl"); } +export function getPrivacyPolicyEnabled() { + return getEmbeddedVariable("privacypolicyurl") !== ""; +} + +export function getPrivacyPolicyURL() { + return getEmbeddedVariable("privacypolicyurl"); +} + +export function getPrivacyPolicyRequireAccept() { + return getEmbeddedVariable("privacypolicyaccept") === "true"; +} + export function getTheme() { return getEmbeddedVariable("theme"); }