diff options
147 files changed, 7861 insertions, 2854 deletions
diff --git a/api/openapi.yml b/api/openapi.yml index b51f5d329..7979d6f94 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: @@ -1135,6 +1307,32 @@ paths:    {{- end }}  components:    parameters: +    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 @@ -1170,6 +1368,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/config.template.yml b/config.template.yml index 4743bf515..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  ## 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/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/data/configkeys.json b/docs/data/configkeys.json index b5bed61d4..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"},{"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 +[{"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 4743bf515..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  ## 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/keys.go b/internal/configuration/schema/keys.go index 171f36201..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", 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/const.go b/internal/configuration/validator/const.go index bbc59a87c..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 ( @@ -332,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} @@ -369,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/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 2403bf779..932d1ab5a 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) +	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 ( @@ -108,13 +119,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" @@ -123,8 +127,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 <user>:<password> 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 "<anonymous>" +	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 s, err = ctx.GetSession(); err != nil { +		ctx.ReplyUnauthorized() +		return +	} -	if userSession.IsAnonymous() { +	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 8a3a8cfc0..a29802f2e 100644 --- a/internal/handlers/handler_register_webauthn.go +++ b/internal/handlers/handler_register_webauthn.go @@ -10,6 +10,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"  )  // WebauthnIdentityStart the handler for initiating the identity validation. @@ -31,12 +32,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) @@ -88,11 +96,19 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) {  		w    *webauthn.WebAuthn  		user *model.WebauthnUser +		userSession session.UserSession +  		attestationResponse *protocol.ParsedCredentialCreationData  		credential          *webauthn.Credential  	) -	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 = "<anonymous>" -	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<User>\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("<a href=\"%s\">%d %s</a>", 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("<a href=\"%s\">%d %s</a>", 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, "<a href=\"https://login.example.com/?rd=https%3A%2F%2Ftwo-factor.example.com&rm=GET\">302 Found</a>", -		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, "<a href=\"https://login.example.com/?rd=https%3A%2F%2Ftwo-factor.example.com&rm=GET\">302 Found</a>", -		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, "<a href=\"https://login.example.com/?rd=https%3A%2F%2Ftwo-factor.example.com&rm=POST\">303 See Other</a>", -		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, "<a href=\"https://auth.example.com/?rd=https%3A%2F%2Ftwo-factor.example.com\">302 Found</a>", -		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, "<a href=\"https://auth.example.com/?rd=https%3A%2F%2Ftwo-factor.example.com\">302 Found</a>", -		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/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 517d297a6..6079f1ac0 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 036517964..8d1f06174 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" @@ -128,12 +130,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  		} @@ -143,14 +150,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() @@ -159,41 +166,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  } -// XOriginalURL returns the content of the X-Original-URL header. -func (ctx *AutheliaCtx) XOriginalURL() []byte { -	return ctx.RequestCtx.Request.Header.PeekBytes(headerXOriginalURL) +// XOriginalMethod returns the content of the X-Original-Method header. +func (ctx *AutheliaCtx) XOriginalMethod() []byte { +	return ctx.Request.Header.PeekBytes(headerXOriginalMethod)  } -// XOriginalMethod return the content of the X-Original-Method header. -func (ctx *AutheliaCtx) XOriginalMethod() []byte { -	return ctx.RequestCtx.Request.Header.PeekBytes(headerXOriginalMethod) +// XOriginalURL returns the content of the X-Original-URL header. +func (ctx *AutheliaCtx) XOriginalURL() []byte { +	return ctx.Request.Header.PeekBytes(headerXOriginalURL)  } -// 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)  	} @@ -202,7 +228,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  	} @@ -213,7 +239,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(),  	}  } @@ -222,13 +248,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 { @@ -253,46 +283,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 domain, err = ctx.GetCookieDomain(); err != nil { +			return nil, err +		} -	if cookieDomain, 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.session, nil +} + +// 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)  	} -	return ctx.Providers.SessionProvider.Get(cookieDomain) +	return ctx.Providers.SessionProvider.Get(domain)  } -// 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() +// 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  	} -	userSession, err := provider.GetSession(ctx.RequestCtx) -	if err != nil { +	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 { @@ -302,7 +368,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 { @@ -312,7 +378,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 { @@ -349,6 +415,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) @@ -368,52 +464,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) -		} +// 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() -		ctx.Logger.Trace("Using X-Original-URL header content as targeted site URL") +	if forwardedProto == nil { +		return nil, ErrMissingXForwardedProto +	} -		return parsedURL, nil +	if forwardedHost == nil { +		return nil, ErrMissingXForwardedHost  	} -	forwardedProto, forwardedHost, forwardedURI := ctx.XForwardedProto(), ctx.XForwardedHost(), ctx.XForwardedURI() +	value := utils.BytesJoin(forwardedProto, protoHostSeparator, forwardedHost, forwardedURI) -	if forwardedProto == nil { -		return nil, errMissingXForwardedProto +	if requestURI, err = url.ParseRequestURI(string(value)); err != nil { +		return nil, fmt.Errorf("failed to parse X-Forwarded Headers: %w", err)  	} -	if forwardedHost == nil { -		return nil, errMissingXForwardedHost +	return requestURI, 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  	} -	var requestURI string +	if requestURI, err = url.ParseRequestURI(string(value)); err != nil { +		return nil, fmt.Errorf("failed to parse X-Original-URL header: %w", err) +	} -	forwardedProto = append(forwardedProto, protoHostSeparator...) -	requestURI = string(append(forwardedProto, -		append(forwardedHost, forwardedURI...)...)) +	return requestURI, nil +} -	parsedURL, err := url.ParseRequestURI(requestURI) -	if err != nil { -		return nil, fmt.Errorf("Unable to parse URL %s: %v", requestURI, err) +// 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  	} +} -	ctx.Logger.Tracef("Using X-Fowarded-Proto, X-Forwarded-Host and X-Forwarded-URI headers " + -		"to construct targeted site URL") +// IssuerURL returns the expected Issuer. +func (ctx *AutheliaCtx) IssuerURL() (issuerURL *url.URL, err error) { +	issuerURL = &url.URL{ +		Scheme: strProtoHTTPS, +	} -	return parsedURL, nil +	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. @@ -452,31 +584,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_first_factor.go b/internal/middlewares/require_first_factor.go index cd104583d..381eaaefd 100644 --- a/internal/middlewares/require_first_factor.go +++ b/internal/middlewares/require_first_factor.go @@ -7,7 +7,7 @@ import (  // Require1FA check if user has enough permissions to execute the next handler.  func Require1FA(next RequestHandler) RequestHandler {  	return func(ctx *AutheliaCtx) { -		if ctx.GetSession().AuthenticationLevel < authentication.OneFactor { +		if s, err := ctx.GetSession(); err != nil || s.AuthenticationLevel < authentication.OneFactor {  			ctx.ReplyForbidden()  			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 14893a606..0d04e738c 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 277cb16f9..106fea7fd 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 { @@ -153,10 +157,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) + +	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("/api/verify", middlewares.Wrap(metricsVRMW, middleware(handlers.VerifyGET(config.AuthenticationBackend)))) -	r.ANY("/api/verify/{path:*}", middlewares.Wrap(metricsVRMW, middleware(handlers.VerifyGET(config.AuthenticationBackend)))) +			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)) @@ -217,11 +259,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)  	} @@ -315,7 +357,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/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 7592e67ae..3e4de786c 100644 --- a/internal/server/template.go +++ b/internal/server/template.go @@ -210,6 +210,7 @@ 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 { @@ -241,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. @@ -288,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,  	}  } @@ -324,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 b1fec2124..5cd016775 100644 --- a/internal/suites/const.go +++ b/internal/suites/const.go @@ -2,6 +2,7 @@ package suites  import (  	"fmt" +	"os"  	"github.com/authelia/authelia/v4/internal/configuration/schema"  ) @@ -13,6 +14,11 @@ var (  	Example3DotCom = "example3.com:8080"  ) +// 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 {  	if baseDomain == "" { 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 73ded6bcd..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:/.well-known,/api,/locales,/devworkflow,/jwks.json' +      - '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(`/devworkflow`) || Host(`login.example.com`) && Path(`${PathPrefix}/devworkflow`) || Host(`login.example.com`) && Path(`/jwks.json`) || Host(`login.example.com`) && Path(`${PathPrefix}/jwks.json`) || Host(`login.example.com`) && Path(`/static/media/logo.png`) || Host(`login.example.com`) && Path(`${PathPrefix}/static/media/logo.png`)'  # 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 b9d0d89ee..7096787d6 100644 --- a/internal/suites/example/compose/caddy/Caddyfile +++ b/internal/suites/example/compose/caddy/Caddyfile @@ -30,7 +30,7 @@ login.example.com:8080 {  		}  		reverse_proxy /devworkflow authelia-backend:9091 { -			import tls-transport +        	import tls-transport          }  		reverse_proxy /jwks.json authelia-backend:9091 { @@ -59,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 796558927..f43d28649 100644 --- a/internal/suites/example/compose/envoy/envoy.yaml +++ b/internal/suites/example/compose/envoy/envoy.yaml @@ -79,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 @@ -91,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/haproxy.cfg b/internal/suites/example/compose/haproxy/haproxy.cfg index e1ecdefd4..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,11 +27,11 @@ frontend fe_http      bind *:8080 ssl crt /usr/local/etc/haproxy/haproxy.pem      acl api-path path_beg -i /api -	acl wellknown-path path_beg -i /.well-known -	acl locales-path path_beg -i /locales  	acl devworkflow-path path -i -m end /devworkflow -	acl jwks-path path -i -m end /jwks.json      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 @@ -45,20 +47,18 @@ 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 || wellknown-path || locales-path || devworkflow-path || jwks-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 } 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)?\.(?<basedomain>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)?\.(?<basedomain>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_multiple_cookie_domain_test.go b/internal/suites/scenario_multiple_cookie_domain_test.go index b0d0604a7..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:   NewRodSuite(""), -		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_one_factor_test.go b/internal/suites/scenario_one_factor_test.go index adef3c575..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" @@ -48,6 +50,36 @@ 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(), 20*time.Second)  	defer func() { diff --git a/internal/suites/scenario_two_factor_test.go b/internal/suites/scenario_two_factor_test.go index caf898d09..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" @@ -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/suite_multi_cookie_domain_test.go b/internal/suites/suite_multi_cookie_domain_test.go index 4c27e9200..33861fd6a 100644 --- a/internal/suites/suite_multi_cookie_domain_test.go +++ b/internal/suites/suite_multi_cookie_domain_test.go @@ -19,15 +19,15 @@ type MultiCookieDomainSuite struct {  }  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_pathprefix_test.go b/internal/suites/suite_pathprefix_test.go index 9f2cd5912..a821ca17a 100644 --- a/internal/suites/suite_pathprefix_test.go +++ b/internal/suites/suite_pathprefix_test.go @@ -16,6 +16,10 @@ func NewPathPrefixSuite() *PathPrefixSuite {  	}  } +func (s *PathPrefixSuite) TestCheckEnv() { +	s.Assert().Equal("/auth", GetPathPrefix()) +} +  func (s *PathPrefixSuite) Test1FAScenario() {  	suite.Run(s.T(), New1FAScenario())  } @@ -32,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_standalone_test.go b/internal/suites/suite_standalone_test.go index f82acbe41..79e9e3d8f 100644 --- a/internal/suites/suite_standalone_test.go +++ b/internal/suites/suite_standalone_test.go @@ -274,7 +274,7 @@ func (s *StandaloneSuite) TestShouldVerifyAPIVerifyRedirectFromXOriginalURL() {  	s.Assert().NoError(err)  	urlEncodedAdminURL := url.QueryEscape(AdminBaseURL) -	s.Assert().Equal(fmt.Sprintf("<a href=\"%s\">302 Found</a>", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body)) +	s.Assert().Equal(fmt.Sprintf("<a href=\"%s\">302 Found</a>", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s&rm=GET", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body))  }  func (s *StandaloneSuite) TestShouldVerifyAPIVerifyRedirectFromXOriginalHostURI() { @@ -293,7 +293,7 @@ func (s *StandaloneSuite) TestShouldVerifyAPIVerifyRedirectFromXOriginalHostURI(  	s.Assert().NoError(err)  	urlEncodedAdminURL := url.QueryEscape(SecureBaseURL + "/") -	s.Assert().Equal(fmt.Sprintf("<a href=\"%s\">302 Found</a>", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body)) +	s.Assert().Equal(fmt.Sprintf("<a href=\"%s\">302 Found</a>", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s&rm=GET", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body))  }  func (s *StandaloneSuite) TestShouldRecordMetrics() { diff --git a/internal/suites/utils.go b/internal/suites/utils.go index f6b8e89eb..4ed46ad91 100644 --- a/internal/suites/utils.go +++ b/internal/suites/utils.go @@ -53,11 +53,18 @@ func GetBrowserPath() (path string, err error) {  // GetLoginBaseURL returns the URL of the login portal and the path prefix if specified.  func GetLoginBaseURL(baseDomain string) string { -	if pathPrefix := os.Getenv("PathPrefix"); 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) { @@ -187,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 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/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 +}  | 
