diff --git a/config.template.yml b/config.template.yml index 6ced3277a..cea0b6663 100644 --- a/config.template.yml +++ b/config.template.yml @@ -131,12 +131,15 @@ # authz: # forward-auth: # implementation: 'ForwardAuth' + # headers: ~ # authn_strategies: [] # ext-authz: # implementation: 'ExtAuthz' + # headers: ~ # authn_strategies: [] # auth-request: # implementation: 'AuthRequest' + # headers: ~ # authn_strategies: [] # legacy: # implementation: 'Legacy' diff --git a/docs/content/configuration/miscellaneous/server-endpoints-authz.md b/docs/content/configuration/miscellaneous/server-endpoints-authz.md index a3b363221..908db9251 100644 --- a/docs/content/configuration/miscellaneous/server-endpoints-authz.md +++ b/docs/content/configuration/miscellaneous/server-endpoints-authz.md @@ -33,6 +33,15 @@ server: authz: forward-auth: implementation: 'ForwardAuth' + headers: + Remote-User: + user_attribute: 'username' + Remote-Groups: + user_attribute: 'groups' + Remote-Name: + user_attribute: 'display_name' + Remote-Email: + user_attribute: 'email' authn_strategies: - name: 'HeaderAuthorization' schemes: @@ -41,6 +50,15 @@ server: - name: 'CookieSession' ext-authz: implementation: 'ExtAuthz' + headers: + Remote-User: + user_attribute: 'username' + Remote-Groups: + user_attribute: 'groups' + Remote-Name: + user_attribute: 'display_name' + Remote-Email: + user_attribute: 'email' authn_strategies: - name: 'HeaderAuthorization' schemes: @@ -49,6 +67,15 @@ server: - name: 'CookieSession' auth-request: implementation: 'AuthRequest' + headers: + Remote-User: + user_attribute: 'username' + Remote-Groups: + user_attribute: 'groups' + Remote-Name: + user_attribute: 'display_name' + Remote-Email: + user_attribute: 'email' authn_strategies: - name: 'HeaderAuthorization' schemes: @@ -80,6 +107,31 @@ The underlying implementation for the endpoint. Valid case-sensitive values are `AuthRequest`, and `Legacy`. Read more about the implementations in the [reference guide](../../reference/guides/proxy-authorization.md#implementations). +### headers + +{{< confkey type="dictionary(object)" required="no" >}} + +The headers value contains a dictionary where the dictionary key is the header name and the dictionary value is an +object which determines the header value. + +The header name must only contain valid header name characters, and must not be a standard or otherwise reserved header +such as `Content-Type`, `Location`, `Set-Cookie`, `WWW-Authenticate`, or any of the `Access-Control-*`, `X-Forwarded-*`, +and `X-Original-*` headers. As header names are case-insensitive the same header must not be configured more than once +in differing cases. + +Configuring this option replaces the default headers entirely, so the `Remote-User`, `Remote-Groups`, `Remote-Name`, and +`Remote-Email` headers must be included if they are still required. + +#### user_attribute + +{{< confkey type="string" required="yes" >}} + +The user attribute to derive the header value from. This attribute can be one available from the authentication backend +or one determined via the [User Attribute Definitions](../definitions/user-attributes.md). Attribute names which are not +known to Authelia, and attributes which are not mapped by the configured authentication backend, cause a startup error. + +Attributes which resolve to a list are joined with a comma. + ### authn_strategies {{< confkey type="list" required="no" >}} diff --git a/internal/authentication/types.go b/internal/authentication/types.go index 13c6fa5c3..41366949d 100644 --- a/internal/authentication/types.go +++ b/internal/authentication/types.go @@ -29,6 +29,10 @@ type UserDetails struct { // Addresses returns the Emails []string as []mail.Address formatted with DisplayName as the Name attribute. func (d *UserDetails) Addresses() (addresses []mail.Address) { + if d == nil { + return nil + } + if len(d.Emails) == 0 { return nil } @@ -47,21 +51,37 @@ func (d *UserDetails) Addresses() (addresses []mail.Address) { // GetUsername returns the username. func (d *UserDetails) GetUsername() (username string) { + if d == nil { + return "" + } + return d.Username } // GetGroups returns the groups. func (d *UserDetails) GetGroups() (groups []string) { + if d == nil { + return nil + } + return d.Groups } // GetDisplayName returns the display name. func (d *UserDetails) GetDisplayName() (name string) { + if d == nil { + return "" + } + return d.DisplayName } // GetEmails returns the emails. func (d *UserDetails) GetEmails() (emails []string) { + if d == nil { + return nil + } + return d.Emails } @@ -87,58 +107,147 @@ type UserDetailsExtended struct { *UserDetails } +// Addresses returns the Emails []string as []mail.Address formatted with DisplayName as the Name attribute. +func (d *UserDetailsExtended) Addresses() (addresses []mail.Address) { + if d == nil { + return nil + } + + return d.UserDetails.Addresses() +} + +// GetUsername returns the username. +func (d *UserDetailsExtended) GetUsername() (username string) { + if d == nil { + return "" + } + + return d.UserDetails.GetUsername() +} + +// GetGroups returns the groups. +func (d *UserDetailsExtended) GetGroups() (groups []string) { + if d == nil { + return nil + } + + return d.UserDetails.GetGroups() +} + +// GetDisplayName returns the display name. +func (d *UserDetailsExtended) GetDisplayName() (name string) { + if d == nil { + return "" + } + + return d.UserDetails.GetDisplayName() +} + +// GetEmails returns the emails. +func (d *UserDetailsExtended) GetEmails() (emails []string) { + if d == nil { + return nil + } + + return d.UserDetails.GetEmails() +} + // GetGivenName returns the given name. func (d *UserDetailsExtended) GetGivenName() (given string) { + if d == nil { + return "" + } + return d.GivenName } // GetFamilyName returns the family name. func (d *UserDetailsExtended) GetFamilyName() (family string) { + if d == nil { + return "" + } + return d.FamilyName } // GetMiddleName returns the middle name. func (d *UserDetailsExtended) GetMiddleName() (middle string) { + if d == nil { + return "" + } + return d.MiddleName } // GetNickname returns the nickname. func (d *UserDetailsExtended) GetNickname() (nickname string) { + if d == nil { + return "" + } + return d.Nickname } // GetProfile returns the profile URL as a string. func (d *UserDetailsExtended) GetProfile() (profile string) { + if d == nil { + return "" + } + return stringURL(d.Profile) } // GetPicture returns the picture URL as a string. func (d *UserDetailsExtended) GetPicture() (picture string) { + if d == nil { + return "" + } + return stringURL(d.Picture) } // GetWebsite returns the website URL as a string. func (d *UserDetailsExtended) GetWebsite() (website string) { + if d == nil { + return "" + } + return stringURL(d.Website) } // GetGender returns the gender. func (d *UserDetailsExtended) GetGender() (gender string) { + if d == nil { + return "" + } + return d.Gender } // GetBirthdate returns the birthdate. func (d *UserDetailsExtended) GetBirthdate() (birthdate string) { + if d == nil { + return "" + } + return d.Birthdate } // GetZoneInfo returns the zone information. func (d *UserDetailsExtended) GetZoneInfo() (info string) { + if d == nil { + return "" + } + return d.ZoneInfo } // GetLocale returns the locale as a string. func (d *UserDetailsExtended) GetLocale() (locale string) { + if d == nil { + return "" + } + if d.Locale == nil { return "" } @@ -148,16 +257,28 @@ func (d *UserDetailsExtended) GetLocale() (locale string) { // GetPhoneNumber returns the phone number without the extension. func (d *UserDetailsExtended) GetPhoneNumber() (number string) { + if d == nil { + return "" + } + return d.PhoneNumber } // GetPhoneExtension returns the phone extension. func (d *UserDetailsExtended) GetPhoneExtension() (extension string) { + if d == nil { + return "" + } + return d.PhoneExtension } // GetPhoneNumberRFC3966 returns the phone number and extension formatted as per RFC3966. func (d *UserDetailsExtended) GetPhoneNumberRFC3966() (number string) { + if d == nil { + return "" + } + if d.PhoneNumber == "" { return "" } @@ -171,6 +292,10 @@ func (d *UserDetailsExtended) GetPhoneNumberRFC3966() (number string) { // GetStreetAddress returns the street address. func (d *UserDetailsExtended) GetStreetAddress() (address string) { + if d == nil { + return "" + } + if d.Address == nil { return "" } @@ -180,6 +305,10 @@ func (d *UserDetailsExtended) GetStreetAddress() (address string) { // GetLocality returns the locality. func (d *UserDetailsExtended) GetLocality() (locality string) { + if d == nil { + return "" + } + if d.Address == nil { return "" } @@ -189,6 +318,10 @@ func (d *UserDetailsExtended) GetLocality() (locality string) { // GetRegion returns the region. func (d *UserDetailsExtended) GetRegion() (region string) { + if d == nil { + return "" + } + if d.Address == nil { return "" } @@ -198,6 +331,10 @@ func (d *UserDetailsExtended) GetRegion() (region string) { // GetPostalCode returns the postal code. func (d *UserDetailsExtended) GetPostalCode() (postcode string) { + if d == nil { + return "" + } + if d.Address == nil { return "" } @@ -207,6 +344,10 @@ func (d *UserDetailsExtended) GetPostalCode() (postcode string) { // GetCountry returns the country. func (d *UserDetailsExtended) GetCountry() (country string) { + if d == nil { + return "" + } + if d.Address == nil { return "" } @@ -216,6 +357,10 @@ func (d *UserDetailsExtended) GetCountry() (country string) { // GetExtra returns the extra attributes. func (d *UserDetailsExtended) GetExtra() (extra map[string]any) { + if d == nil { + return nil + } + return d.Extra } diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 6ced3277a..cea0b6663 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -131,12 +131,15 @@ # authz: # forward-auth: # implementation: 'ForwardAuth' + # headers: ~ # authn_strategies: [] # ext-authz: # implementation: 'ExtAuthz' + # headers: ~ # authn_strategies: [] # auth-request: # implementation: 'AuthRequest' + # headers: ~ # authn_strategies: [] # legacy: # implementation: 'Legacy' diff --git a/internal/configuration/schema/const.go b/internal/configuration/schema/const.go index 8f34ded40..cf4e6c519 100644 --- a/internal/configuration/schema/const.go +++ b/internal/configuration/schema/const.go @@ -84,6 +84,20 @@ const ( RefreshIntervalDefault = time.Minute * 5 ) +const ( + // HeaderRemoteUser is the header which by default communicates the username of the authenticated user. + HeaderRemoteUser = "Remote-User" + + // HeaderRemoteGroups is the header which by default communicates the groups of the authenticated user. + HeaderRemoteGroups = "Remote-Groups" + + // HeaderRemoteName is the header which by default communicates the display name of the authenticated user. + HeaderRemoteName = "Remote-Name" + + // HeaderRemoteEmail is the header which by default communicates the email of the authenticated user. + HeaderRemoteEmail = "Remote-Email" +) + const ( // LDAPImplementationCustom is the string for the custom LDAP implementation. LDAPImplementationCustom = "custom" diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go index b7d87b963..ee04af027 100644 --- a/internal/configuration/schema/keys.go +++ b/internal/configuration/schema/keys.go @@ -343,6 +343,9 @@ var Keys = []string{ "server.endpoints.authz.*.authn_strategies[].name", "server.endpoints.authz.*.authn_strategies[].scheme_basic_cache_lifespan", "server.endpoints.authz.*.authn_strategies[].schemes", + "server.endpoints.authz.*.headers", + "server.endpoints.authz.*.headers.*", + "server.endpoints.authz.*.headers.*.user_attribute", "server.endpoints.authz.*.implementation", "server.endpoints.enable_expvars", "server.endpoints.enable_pprof", diff --git a/internal/configuration/schema/server.go b/internal/configuration/schema/server.go index 338818211..fb1de7b31 100644 --- a/internal/configuration/schema/server.go +++ b/internal/configuration/schema/server.go @@ -49,9 +49,16 @@ type ServerEndpointHealth struct { type ServerEndpointsAuthz struct { Implementation string `koanf:"implementation" yaml:"implementation,omitempty" toml:"implementation,omitempty" json:"implementation,omitempty" jsonschema:"enum=ForwardAuth,enum=AuthRequest,enum=ExtAuthz,enum=Legacy,title=Implementation" jsonschema_description:"The specific Authorization implementation to use for this endpoint."` + Headers map[string]ServerEndpointsAuthzHeader `koanf:"headers" yaml:"headers,omitempty" toml:"headers,omitempty" json:"headers,omitempty" jsonschema:"title=Headers" jsonschema_description:"The specific Authorization headers to use for this endpoint."` + AuthnStrategies []ServerEndpointsAuthzAuthnStrategy `koanf:"authn_strategies" yaml:"authn_strategies,omitempty" toml:"authn_strategies,omitempty" json:"authn_strategies,omitempty" jsonschema:"title=Authn Strategies" jsonschema_description:"The specific Authorization strategies to use for this endpoint."` } +// ServerEndpointsAuthzHeader is the Authz endpoint response header configuration for the HTTP server. +type ServerEndpointsAuthzHeader struct { + UserAttribute string `koanf:"user_attribute" yaml:"user_attribute,omitempty" toml:"user_attribute,omitempty" json:"user_attribute,omitempty" jsonschema:"required,title=User Attribute" jsonschema_description:"The user attribute to use for the header."` +} + // ServerEndpointsAuthzAuthnStrategy is the Authz endpoints configuration for the HTTP server. type ServerEndpointsAuthzAuthnStrategy struct { Name string `koanf:"name" yaml:"name,omitempty" toml:"name,omitempty" json:"name,omitempty" jsonschema:"enum=HeaderAuthorization,enum=HeaderProxyAuthorization,enum=HeaderAuthRequestProxyAuthorization,enum=HeaderLegacy,enum=CookieSession,title=Name" jsonschema_description:"The name of the Authorization strategy to use."` @@ -129,6 +136,20 @@ var DefaultServerConfiguration = Server{ }, AuthzEndpointNameAuthRequest: { Implementation: AuthzImplementationAuthRequest, + Headers: map[string]ServerEndpointsAuthzHeader{ + HeaderRemoteUser: { + UserAttribute: "username", + }, + HeaderRemoteGroups: { + UserAttribute: "groups", + }, + HeaderRemoteName: { + UserAttribute: "display_name", + }, + HeaderRemoteEmail: { + UserAttribute: "email", + }, + }, AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{ { Name: AuthzStrategyHeaderAuthorization, @@ -141,6 +162,20 @@ var DefaultServerConfiguration = Server{ }, AuthzEndpointNameExtAuthz: { Implementation: AuthzImplementationExtAuthz, + Headers: map[string]ServerEndpointsAuthzHeader{ + HeaderRemoteUser: { + UserAttribute: "username", + }, + HeaderRemoteGroups: { + UserAttribute: "groups", + }, + HeaderRemoteName: { + UserAttribute: "display_name", + }, + HeaderRemoteEmail: { + UserAttribute: "email", + }, + }, AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{ { Name: AuthzStrategyHeaderAuthorization, @@ -153,6 +188,20 @@ var DefaultServerConfiguration = Server{ }, AuthzEndpointNameForwardAuth: { Implementation: AuthzImplementationForwardAuth, + Headers: map[string]ServerEndpointsAuthzHeader{ + HeaderRemoteUser: { + UserAttribute: "username", + }, + HeaderRemoteGroups: { + UserAttribute: "groups", + }, + HeaderRemoteName: { + UserAttribute: "display_name", + }, + HeaderRemoteEmail: { + UserAttribute: "email", + }, + }, AuthnStrategies: []ServerEndpointsAuthzAuthnStrategy{ { Name: AuthzStrategyHeaderAuthorization, diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 1fc5640a5..25a1556f2 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -445,22 +445,28 @@ const ( errFmtServerPathNotEndForwardSlash = "server: option 'address' must be a single subpath (i.e. '%s'), but '%s' contains multiple segments" errFmtServerPathAlphaNumeric = "server: option 'address' must have a path with only alphanumeric characters but it's configured as '%s'" - errFmtServerEndpointsAuthzImplementation = "server: endpoints: authz: %s: option 'implementation' must be one of %s but it's configured as '%s'" - errFmtServerEndpointsAuthzStrategy = "server: endpoints: authz: %s: authn_strategies: option 'name' must be one of %s but it's configured as '%s'" - errFmtServerEndpointsAuthzSchemes = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' must only include the values %s but has '%s'" - errFmtServerEndpointsAuthzSchemesInvalidForStrategy = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' is not valid for the strategy" - errFmtServerEndpointsAuthzStrategyNoName = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option 'name' must be configured" - errFmtServerEndpointsAuthzStrategySchemeOnlyOption = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option '%s' can't be configured unless the '%s' scheme is configured but only the %s schemes are configured" - 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" - errFmtServerEndpointsHealthProviderUnknown = "server: endpoints: health: option 'providers' must only include the values %s but it's configured as '%s'" - errFmtServerEndpointsHealthProviderDuplicate = "server: endpoints: health: option 'providers' has a duplicate value '%s'" - errFmtServerEndpointsHealthCacheNegative = "server: endpoints: health: option 'cache' must be greater than or equal to 0 but it's configured as '%s'" - errFmtServerEndpointsHealthDetailedNotVerbose = "server: endpoints: health: option 'detailed' has no effect unless option 'verbose' is enabled" - errFmtServerEndpointsRateLimitsBucketPeriodZero = "server: endpoints: rate_limits: %s: bucket %d: option 'period' must have a value" - errFmtServerEndpointsRateLimitsBucketPeriodTooLow = "server: endpoints: rate_limits: %s: bucket %d: option 'period' has a value of '%s' but it must be greater than 10 seconds" - errFmtServerEndpointsRateLimitsBucketRequestsZero = "server: endpoints: rate_limits: %s: bucket %d: option 'requests' has a value of '%d' but it must be greater than 1" - errFmtServerEndpointsAuthzInvalidName = "server: endpoints: authz: %s: contains invalid characters" + errFmtServerEndpointsAuthzOptionLegacy = "server: endpoints: authz: %s: option '%s' must not be configured for the 'Legacy' implementation" + errFmtServerEndpointsAuthzHeaderInvalidName = "server: endpoints: authz: %s: headers: %s: header name must only contain valid header name characters" + errFmtServerEndpointsAuthzHeaderReservedName = "server: endpoints: authz: %s: headers: %s: header name must not be a standard or reserved header" + errFmtServerEndpointsAuthzHeaderDuplicateName = "server: endpoints: authz: %s: headers: %s: header name duplicates the '%s' header as header names are case-insensitive" + errFmtServerEndpointsAuthzHeaderUserAttributeMissing = "server: endpoints: authz: %s: headers: %s: option 'user_attribute' is required" + errFmtServerEndpointsAuthzHeaderUserAttribute = "server: endpoints: authz: %s: headers: %s: option 'user_attribute' must be a known user attribute but it's configured as '%s'" + errFmtServerEndpointsAuthzImplementation = "server: endpoints: authz: %s: option 'implementation' must be one of %s but it's configured as '%s'" + errFmtServerEndpointsAuthzStrategy = "server: endpoints: authz: %s: authn_strategies: option 'name' must be one of %s but it's configured as '%s'" + errFmtServerEndpointsAuthzSchemes = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' must only include the values %s but has '%s'" + errFmtServerEndpointsAuthzSchemesInvalidForStrategy = "server: endpoints: authz: %s: authn_strategies: strategy #%d (%s): option 'schemes' is not valid for the strategy" + errFmtServerEndpointsAuthzStrategyNoName = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option 'name' must be configured" + errFmtServerEndpointsAuthzStrategySchemeOnlyOption = "server: endpoints: authz: %s: authn_strategies: strategy #%d: option '%s' can't be configured unless the '%s' scheme is configured but only the %s schemes are configured" + 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" + errFmtServerEndpointsHealthProviderUnknown = "server: endpoints: health: option 'providers' must only include the values %s but it's configured as '%s'" + errFmtServerEndpointsHealthProviderDuplicate = "server: endpoints: health: option 'providers' has a duplicate value '%s'" + errFmtServerEndpointsHealthCacheNegative = "server: endpoints: health: option 'cache' must be greater than or equal to 0 but it's configured as '%s'" + errFmtServerEndpointsHealthDetailedNotVerbose = "server: endpoints: health: option 'detailed' has no effect unless option 'verbose' is enabled" + errFmtServerEndpointsRateLimitsBucketPeriodZero = "server: endpoints: rate_limits: %s: bucket %d: option 'period' must have a value" + errFmtServerEndpointsRateLimitsBucketPeriodTooLow = "server: endpoints: rate_limits: %s: bucket %d: option 'period' has a value of '%s' but it must be greater than 10 seconds" + errFmtServerEndpointsRateLimitsBucketRequestsZero = "server: endpoints: rate_limits: %s: bucket %d: option 'requests' has a value of '%d' but it must be greater than 1" + 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" ) @@ -643,15 +649,20 @@ var ( ) var ( - reKeyReplacer = regexp.MustCompile(`\[\d+]`) - reDomainCharacters = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$`) - reAuthzEndpointName = regexp.MustCompile(`^[a-zA-Z](([a-zA-Z0-9/._-]*)([a-zA-Z]))?$`) - reOpenIDConnectKID = regexp.MustCompile(`^([a-zA-Z0-9](([a-zA-Z0-9._~-]*)([a-zA-Z0-9]))?)?$`) - reRFC3986Unreserved = regexp.MustCompile(`^[a-zA-Z0-9._~-]+$`) + reKeyReplacer = regexp.MustCompile(`\[\d+]`) + reDomainCharacters = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$`) + reAuthzEndpointName = regexp.MustCompile(`^[a-zA-Z](([a-zA-Z0-9/._-]*)([a-zA-Z]))?$`) + reAuthzEndpointHeaderName = regexp.MustCompile("^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$") + reOpenIDConnectKID = regexp.MustCompile(`^([a-zA-Z0-9](([a-zA-Z0-9._~-]*)([a-zA-Z0-9]))?)?$`) + reRFC3986Unreserved = regexp.MustCompile(`^[a-zA-Z0-9._~-]+$`) ) const ( attributeUserUsername = "username" + attributeUserEmailVerified = "email_verified" + attributeUserEmailsExtra = "emails_extra" + attributeUserUpdatedAt = "updated_at" + attributeUserAddress = "address" attributeUserGroups = "groups" attributeUserDisplayName = "display_name" attributeUserEmail = "email" @@ -669,13 +680,47 @@ const ( attributeUserLocale = "locale" attributeUserPhoneNumber = "phone_number" attributeUserPhoneExtension = "phone_extension" - attributeUserStreetAddress = "street_address" - attributeUserLocality = "locality" - attributeUserRegion = "region" - attributeUserPostalCode = "postal_code" - attributeUserCountry = "country" + + attributeUserPhoneNumberRFC3966 = "phone_number_rfc3966" + attributeUserPhoneNumberVerified = "phone_number_verified" + + attributeUserStreetAddress = "street_address" + attributeUserLocality = "locality" + attributeUserRegion = "region" + attributeUserPostalCode = "postal_code" + attributeUserCountry = "country" ) +var reservedAuthzEndpointHeaderNames = []string{ + // Message framing, representation metadata, and hop-by-hop headers. + "connection", "content-disposition", "content-encoding", "content-language", "content-length", + "content-location", "content-md5", "content-range", "content-type", "date", "expect", "forwarded", "host", + "keep-alive", "max-forwards", "proxy-connection", "range", "referer", "server", "te", "trailer", + "transfer-encoding", "upgrade", "user-agent", "via", "warning", + + // Authentication, authorization, and session headers. + "authorization", "cookie", "proxy-authenticate", "proxy-authorization", "session-username", "set-cookie", + "www-authenticate", + + // Redirection, caching, and conditional headers. + "age", "cache-control", "etag", "expires", "last-modified", "location", "pragma", "refresh", "retry-after", + "vary", + + // Security headers. + "alt-svc", "clear-site-data", "content-security-policy", "content-security-policy-report-only", "expect-ct", + "feature-policy", "nel", "origin", "origin-agent-cluster", "permissions-policy", "referrer-policy", + "report-to", "strict-transport-security", "timing-allow-origin", "x-content-type-options", + "x-dns-prefetch-control", "x-download-options", "x-frame-options", "x-permitted-cross-domain-policies", + "x-xss-protection", + + // Headers utilized by Authelia and the proxies which integrate with it. + "x-authelia-url", "x-real-ip", +} + +var reservedAuthzEndpointHeaderPrefixes = []string{ + "access-control-", "if-", "sec-", "x-forwarded-", "x-original-", +} + var validUserAttributes = []string{ attributeUserUsername, attributeUserGroups, diff --git a/internal/configuration/validator/server.go b/internal/configuration/validator/server.go index a4ab120f6..a0bde0052 100644 --- a/internal/configuration/validator/server.go +++ b/internal/configuration/validator/server.go @@ -389,8 +389,6 @@ func validateServerEndpointsAuthzEndpoint(config *schema.Configuration, name str break case "": endpoint.Implementation = schema.AuthzImplementationLegacy - - config.Server.Endpoints.Authz[name] = endpoint default: if !utils.IsStringInSlice(endpoint.Implementation, validAuthzImplementations) { validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzImplementation, name, utils.StringJoinOr(validAuthzImplementations), endpoint.Implementation)) @@ -405,6 +403,104 @@ func validateServerEndpointsAuthzEndpoint(config *schema.Configuration, name str if !reAuthzEndpointName.MatchString(name) { validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzInvalidName, name)) } + + // NOTE: With exclusion of the implementation set any options below this line. + if endpoint.Implementation == schema.AuthzImplementationLegacy && endpoint.Headers != nil { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzOptionLegacy, name, "headers")) + } + + if endpoint.Headers == nil { + switch endpoint.Implementation { + case schema.AuthzImplementationLegacy: + break + case schema.AuthzImplementationAuthRequest: + endpoint.Headers = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameAuthRequest].Headers + case schema.AuthzImplementationExtAuthz: + endpoint.Headers = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameExtAuthz].Headers + case schema.AuthzImplementationForwardAuth: + endpoint.Headers = schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameForwardAuth].Headers + } + } else { + validateServerEndpointsAuthzEndpointHeaders(config, name, endpoint, validator) + } + + config.Server.Endpoints.Authz[name] = endpoint +} + +func validateServerEndpointsAuthzEndpointHeaders(config *schema.Configuration, name string, endpoint schema.ServerEndpointsAuthz, validator *schema.StructValidator) { + headers := make([]string, 0, len(endpoint.Headers)) + + for header := range endpoint.Headers { + headers = append(headers, header) + } + + sort.Strings(headers) + + seen := make(map[string]string, len(headers)) + + for _, header := range headers { + validateServerEndpointsAuthzEndpointHeaderName(name, header, seen, validator) + + switch attribute := endpoint.Headers[header].UserAttribute; { + case attribute == "": + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderUserAttributeMissing, name, header)) + case !isUserAttributeValidAuthz(attribute, config): + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderUserAttribute, name, header, attribute)) + } + } +} + +func validateServerEndpointsAuthzEndpointHeaderName(name, header string, seen map[string]string, validator *schema.StructValidator) { + if !reAuthzEndpointHeaderName.MatchString(header) { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderInvalidName, name, header)) + + return + } + + key := strings.ToLower(header) + + if first, ok := seen[key]; ok { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderDuplicateName, name, header, first)) + + return + } + + seen[key] = header + + if utils.IsStringInSlice(key, reservedAuthzEndpointHeaderNames) { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderReservedName, name, header)) + + return + } + + for _, prefix := range reservedAuthzEndpointHeaderPrefixes { + if strings.HasPrefix(key, prefix) { + validator.Push(fmt.Errorf(errFmtServerEndpointsAuthzHeaderReservedName, name, header)) + + return + } + } +} + +func isUserAttributeValidAuthz(name string, config *schema.Configuration) (valid bool) { + if _, ok := config.Definitions.UserAttributes[name]; ok { + return true + } + + switch name { + case attributeUserEmailVerified, attributeUserEmailsExtra, attributeUserUpdatedAt: + return true + case attributeUserPhoneNumberRFC3966, attributeUserPhoneNumberVerified: + return isUserAttributeValid(attributeUserPhoneNumber, config) + case attributeUserAddress: + return isUserAttributeValid(attributeUserStreetAddress, config) || + isUserAttributeValid(attributeUserLocality, config) || + isUserAttributeValid(attributeUserRegion, config) || + isUserAttributeValid(attributeUserPostalCode, config) || + isUserAttributeValid(attributeUserCountry, config) + default: + return isUserAttributeValid(name, config) + } } //nolint:gocyclo diff --git a/internal/configuration/validator/server_test.go b/internal/configuration/validator/server_test.go index a44f213de..6c158db6a 100644 --- a/internal/configuration/validator/server_test.go +++ b/internal/configuration/validator/server_test.go @@ -744,6 +744,7 @@ func TestServerAuthzEndpointDefaults(t *testing.T) { map[string]schema.ServerEndpointsAuthz{ "example": { Implementation: "ForwardAuth", + Headers: schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameForwardAuth].Headers, AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{ { Name: "HeaderAuthorization", @@ -762,6 +763,7 @@ func TestServerAuthzEndpointDefaults(t *testing.T) { map[string]schema.ServerEndpointsAuthz{ "example": { Implementation: "ForwardAuth", + Headers: schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameForwardAuth].Headers, AuthnStrategies: []schema.ServerEndpointsAuthzAuthnStrategy{ { Name: "HeaderAuthorization", @@ -1078,3 +1080,201 @@ func TestValidateServerEndpointsHealth(t *testing.T) { }) } } + +func TestServerAuthzEndpointHeaders(t *testing.T) { + testCases := []struct { + name string + setup func(config *schema.Configuration) + headers map[string]schema.ServerEndpointsAuthzHeader + errs []string + }{ + { + name: "ShouldAllowDefaultAttributes", + headers: map[string]schema.ServerEndpointsAuthzHeader{ + schema.HeaderRemoteUser: {UserAttribute: "username"}, + schema.HeaderRemoteGroups: {UserAttribute: "groups"}, + schema.HeaderRemoteName: {UserAttribute: "display_name"}, + schema.HeaderRemoteEmail: {UserAttribute: "email"}, + }, + }, + { + name: "ShouldAllowDerivedAttributes", + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote-Emails-Extra": {UserAttribute: "emails_extra"}, + "Remote-Email-Verified": {UserAttribute: "email_verified"}, + "Remote-Updated-At": {UserAttribute: "updated_at"}, + "Remote-Address": {UserAttribute: "address"}, + "Remote-Phone-Number-RFC3966": {UserAttribute: "phone_number_rfc3966"}, + "Remote-Phone-Number-Verified": {UserAttribute: "phone_number_verified"}, + }, + }, + { + name: "ShouldAllowExtraAttributeFromFileBackend", + setup: func(config *schema.Configuration) { + config.AuthenticationBackend.File.ExtraAttributes = map[string]schema.AuthenticationBackendExtraAttribute{ + "employee_id": {ValueType: "integer"}, + } + }, + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote-Employee-Id": {UserAttribute: "employee_id"}, + }, + }, + { + name: "ShouldAllowDefinedUserAttribute", + setup: func(config *schema.Configuration) { + config.Definitions.UserAttributes = map[string]schema.UserAttribute{ + "is_admin": {Expression: "'admins' in groups"}, + } + }, + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote-Is-Admin": {UserAttribute: "is_admin"}, + }, + }, + { + name: "ShouldErrorOnUnknownAttributes", + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote-Mail": {UserAttribute: "mail"}, + "Remote-Teams": {UserAttribute: "teams"}, + }, + errs: []string{ + "server: endpoints: authz: example: headers: Remote-Mail: option 'user_attribute' must be a known user attribute but it's configured as 'mail'", + "server: endpoints: authz: example: headers: Remote-Teams: option 'user_attribute' must be a known user attribute but it's configured as 'teams'", + }, + }, + { + name: "ShouldErrorOnInvalidHeaderNames", + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote User": {UserAttribute: "username"}, + "Remote:Group": {UserAttribute: "groups"}, + }, + errs: []string{ + "server: endpoints: authz: example: headers: Remote User: header name must only contain valid header name characters", + "server: endpoints: authz: example: headers: Remote:Group: header name must only contain valid header name characters", + }, + }, + { + name: "ShouldErrorOnReservedHeaderNames", + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Content-Type": {UserAttribute: "username"}, + "Location": {UserAttribute: "username"}, + "set-cookie": {UserAttribute: "username"}, + "WWW-Authenticate": {UserAttribute: "username"}, + "X-Authelia-URL": {UserAttribute: "username"}, + }, + errs: []string{ + "server: endpoints: authz: example: headers: Content-Type: header name must not be a standard or reserved header", + "server: endpoints: authz: example: headers: Location: header name must not be a standard or reserved header", + "server: endpoints: authz: example: headers: WWW-Authenticate: header name must not be a standard or reserved header", + "server: endpoints: authz: example: headers: X-Authelia-URL: header name must not be a standard or reserved header", + "server: endpoints: authz: example: headers: set-cookie: header name must not be a standard or reserved header", + }, + }, + { + name: "ShouldErrorOnReservedHeaderNamePrefixes", + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Access-Control-Allow-Origin": {UserAttribute: "username"}, + "Sec-Fetch-Site": {UserAttribute: "username"}, + "X-Forwarded-User": {UserAttribute: "username"}, + "X-Original-User": {UserAttribute: "username"}, + }, + errs: []string{ + "server: endpoints: authz: example: headers: Access-Control-Allow-Origin: header name must not be a standard or reserved header", + "server: endpoints: authz: example: headers: Sec-Fetch-Site: header name must not be a standard or reserved header", + "server: endpoints: authz: example: headers: X-Forwarded-User: header name must not be a standard or reserved header", + "server: endpoints: authz: example: headers: X-Original-User: header name must not be a standard or reserved header", + }, + }, + { + name: "ShouldErrorOnDuplicateHeaderNames", + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote-User": {UserAttribute: "username"}, + "remote-user": {UserAttribute: "display_name"}, + }, + errs: []string{ + "server: endpoints: authz: example: headers: remote-user: header name duplicates the 'Remote-User' header as header names are case-insensitive", + }, + }, + { + name: "ShouldAllowUncommonButValidHeaderNames", + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote_User": {UserAttribute: "username"}, + "X-Team+Name": {UserAttribute: "display_name"}, + "Remote-Groups": {UserAttribute: "groups"}, + }, + }, + { + name: "ShouldErrorOnMissingUserAttribute", + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote-User": {}, + }, + errs: []string{ + "server: endpoints: authz: example: headers: Remote-User: option 'user_attribute' is required", + }, + }, + { + name: "ShouldErrorOnAttributeNotMappedByLDAPBackend", + setup: func(config *schema.Configuration) { + config.AuthenticationBackend.File = nil + config.AuthenticationBackend.LDAP = &schema.AuthenticationBackendLDAP{} + }, + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote-Given-Name": {UserAttribute: "given_name"}, + }, + errs: []string{ + "server: endpoints: authz: example: headers: Remote-Given-Name: option 'user_attribute' must be a known user attribute but it's configured as 'given_name'", + }, + }, + { + name: "ShouldAllowAttributeMappedByLDAPBackend", + setup: func(config *schema.Configuration) { + config.AuthenticationBackend.File = nil + config.AuthenticationBackend.LDAP = &schema.AuthenticationBackendLDAP{} + config.AuthenticationBackend.LDAP.Attributes.GivenName = "givenName" + config.AuthenticationBackend.LDAP.Attributes.PhoneNumber = "telephoneNumber" + }, + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote-Given-Name": {UserAttribute: "given_name"}, + "Remote-Phone-Number-RFC3966": {UserAttribute: "phone_number_rfc3966"}, + "Remote-Updated-At": {UserAttribute: "updated_at"}, + }, + }, + { + name: "ShouldErrorOnDerivedAddressNotMappedByLDAPBackend", + setup: func(config *schema.Configuration) { + config.AuthenticationBackend.File = nil + config.AuthenticationBackend.LDAP = &schema.AuthenticationBackendLDAP{} + }, + headers: map[string]schema.ServerEndpointsAuthzHeader{ + "Remote-Address": {UserAttribute: "address"}, + }, + errs: []string{ + "server: endpoints: authz: example: headers: Remote-Address: option 'user_attribute' must be a known user attribute but it's configured as 'address'", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + validator := schema.NewStructValidator() + + config := newDefaultConfig() + + if tc.setup != nil { + tc.setup(&config) + } + + config.Server.Endpoints.Authz = map[string]schema.ServerEndpointsAuthz{ + "example": {Implementation: schema.AuthzImplementationForwardAuth, Headers: tc.headers}, + } + + ValidateServerEndpoints(&config, validator) + + assert.Len(t, validator.Warnings(), 0) + require.Len(t, validator.Errors(), len(tc.errs)) + + for i, expected := range tc.errs { + assert.EqualError(t, validator.Errors()[i], expected) + } + }) + } +} diff --git a/internal/handlers/const.go b/internal/handlers/const.go index dcd818631..005ca163d 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -9,6 +9,7 @@ import ( "github.com/valyala/fasthttp" + "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/oidc" ) @@ -29,10 +30,10 @@ var ( headerProxyAuthenticate = []byte(fasthttp.HeaderProxyAuthenticate) headerSessionUsername = []byte("Session-Username") - headerRemoteUser = []byte("Remote-User") - headerRemoteGroups = []byte("Remote-Groups") - headerRemoteName = []byte("Remote-Name") - headerRemoteEmail = []byte("Remote-Email") + headerRemoteUser = []byte(schema.HeaderRemoteUser) + headerRemoteGroups = []byte(schema.HeaderRemoteGroups) + headerRemoteName = []byte(schema.HeaderRemoteName) + headerRemoteEmail = []byte(schema.HeaderRemoteEmail) ) const ( diff --git a/internal/handlers/handler_authz.go b/internal/handlers/handler_authz.go index 796de94d9..a5b549b98 100644 --- a/internal/handlers/handler_authz.go +++ b/internal/handlers/handler_authz.go @@ -206,8 +206,8 @@ func (authz *Authz) Handler(ctx AuthzContext) { ruleHasSubject, required := ctx.GetProviders().Authorizer.GetRequiredLevel( authorization.Subject{ - Username: authn.Details.Username, - Groups: authn.Details.Groups, + Username: authn.Details.GetUsername(), + Groups: authn.Details.GetGroups(), ClientID: authn.ClientID, IP: ctx.RemoteIP(), }, @@ -251,7 +251,7 @@ func (authz *Authz) Handler(ctx AuthzContext) { handler(ctx, authn, authz.getRedirectionURL(&object, autheliaURL)) case AuthzResultAuthorized: - authz.handleAuthorized(ctx, authn) + authz.handleAuthorized(ctx, authz.headers, authn) } } @@ -307,7 +307,7 @@ func (authz *Authz) authn(ctx AuthzContext, manager session.Manager, object *aut authn.Level = authentication.NotAuthenticated authn.Username = anonymous authn.ClientID = "" - authn.Details = authentication.UserDetails{} + authn.Details = &authentication.UserDetailsExtended{UserDetails: &authentication.UserDetails{}} if strategy.CanHandleUnauthorized() { return authn, strategy, err @@ -321,7 +321,7 @@ func (authz *Authz) authn(ctx AuthzContext, manager session.Manager, object *aut } } - if strategy.CanHandleUnauthorized() { + if strategy != nil && strategy.CanHandleUnauthorized() { return authn, strategy, err } diff --git a/internal/handlers/handler_authz_authn.go b/internal/handlers/handler_authz_authn.go index 256945ccd..9ae963f83 100644 --- a/internal/handlers/handler_authz_authn.go +++ b/internal/handlers/handler_authz_authn.go @@ -93,9 +93,13 @@ func NewHeaderLegacyAuthnStrategy() *HeaderLegacyAuthnStrategy { // CookieSessionAuthnStrategy is a session cookie AuthnStrategy. type CookieSessionAuthnStrategy struct { refresh schema.RefreshIntervalDuration + + extended bool } // Get returns the Authn information for this AuthnStrategy. +// +//nolint:gocyclo func (s *CookieSessionAuthnStrategy) Get(ctx AuthzContext, manager session.Manager, _ *authorization.Object) (authn *Authn, err error) { var userSession session.UserSession @@ -142,16 +146,47 @@ func (s *CookieSessionAuthnStrategy) Get(ctx AuthzContext, manager session.Manag } } + if !s.extended || userSession.Username == "" { + return &Authn{ + Username: friendlyUsername(userSession.Username), + Details: &authentication.UserDetailsExtended{ + UserDetails: &authentication.UserDetails{ + Username: userSession.Username, + DisplayName: userSession.DisplayName, + Emails: userSession.Emails, + Groups: userSession.Groups, + }, + }, + Level: userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA), + Type: AuthnTypeCookie, + }, nil + } + + var details *authentication.UserDetailsExtended + + if details, err = ctx.GetUserProvider().GetDetailsExtended(userSession.Username); err != nil { + if errors.Is(err, authentication.ErrUserNotFound) { + ctx.GetLogger().WithField("username", userSession.Username).Error("Error occurred while attempting to get user details for user: the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login") + + return authn, err + } + + return authn, fmt.Errorf("unable to retrieve details for user '%s': %w", userSession.Username, err) + } else if details == nil || details.GetUsername() == "" { + ctx.GetLogger().WithField("username", userSession.Username).Error("Error occurred while attempting to get user details for user: no user details were returned") + + if err = manager.DestroySession(); err != nil { + ctx.GetLogger().WithError(err).Errorf("Unable to destroy user session") + } + + return authn, fmt.Errorf("unable to retrieve details for user '%s': no user details were returned", userSession.Username) + } + return &Authn{ Username: friendlyUsername(userSession.Username), - Details: authentication.UserDetails{ - Username: userSession.Username, - DisplayName: userSession.DisplayName, - Emails: userSession.Emails, - Groups: userSession.Groups, - }, - Level: userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA), - Type: AuthnTypeCookie, + Details: details, + Level: userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA), + Type: AuthnTypeCookie, }, nil } @@ -180,6 +215,8 @@ type HeaderAuthnStrategy struct { delay middlewares.Delayer basic BasicAuthHandler + + extended bool } // BasicAuthHandler is a function signature that handles basic authentication. This is used to implement caching. The @@ -252,13 +289,13 @@ func (s *HeaderAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, object *a return authn, nil } - var details *authentication.UserDetails + var details *authentication.UserDetailsExtended switch scheme { case model.AuthorizationSchemeBasic: - details, level, err = handleGetBasic(ctx, s.delay, authn, object, s.headerAuthorize, s.basic) + details, level, err = handleGetBasic(ctx, s.delay, authn, object, s.headerAuthorize, s.basic, s.extended) case model.AuthorizationSchemeBearer: - details, clientID, ccs, level, err = handleVerifyGETAuthorizationBearer(ctx, authn, object) + details, clientID, ccs, level, err = handleVerifyGETAuthorizationBearer(ctx, authn, object, s.extended) default: ctx.GetLogger(). WithFields(map[string]any{"scheme": authn.Header.Authorization.SchemeRaw(), "header": string(s.headerAuthorize)}). @@ -288,7 +325,7 @@ func (s *HeaderAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, object *a return authn, fmt.Errorf("failed to determine username from the %s header", s.headerAuthorize) default: authn.Username = friendlyUsername(details.Username) - authn.Details = *details + authn.Details = details } authn.Level = level @@ -322,6 +359,8 @@ func (s *HeaderAuthnStrategy) HandleUnauthorized(ctx AuthzContext, authn *Authn, // HeaderLegacyAuthnStrategy is a legacy header AuthnStrategy which can be switched based on the query parameters. type HeaderLegacyAuthnStrategy struct { delay middlewares.Delayer + + extended bool } // Get returns the Authn information for this AuthnStrategy. @@ -373,16 +412,16 @@ func (s *HeaderLegacyAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, obj } var ( - details *authentication.UserDetails + details *authentication.UserDetailsExtended level authentication.Level ) - if details, level, err = handleGetBasic(ctx, s.delay, authn, object, header, DefaultBasicAuthHandler); err != nil { + if details, level, err = handleGetBasic(ctx, s.delay, authn, object, header, DefaultBasicAuthHandler, s.extended); err != nil { return authn, fmt.Errorf("failed to validate %s header with %s scheme: %w", header, scheme, err) } authn.Username = friendlyUsername(details.Username) - authn.Details = *details + authn.Details = details authn.Level = level return authn, nil @@ -403,7 +442,7 @@ func (s *HeaderLegacyAuthnStrategy) HandleUnauthorized(ctx AuthzContext, authn * handleAuthzUnauthorizedAuthorizationBasic(ctx, authn) } -func handleGetBasic(ctx AuthzContext, delayer middlewares.Delayer, authn *Authn, object *authorization.Object, header []byte, validate BasicAuthHandler) (details *authentication.UserDetails, level authentication.Level, err error) { +func handleGetBasic(ctx AuthzContext, delayer middlewares.Delayer, authn *Authn, object *authorization.Object, header []byte, validate BasicAuthHandler, extended bool) (details *authentication.UserDetailsExtended, level authentication.Level, err error) { var ( ban regulation.BanType value string @@ -421,7 +460,7 @@ func handleGetBasic(ctx AuthzContext, delayer middlewares.Delayer, authn *Authn, return nil, authentication.NotAuthenticated, fmt.Errorf("failed to validate parsed credentials of %s header: the username or password was empty", header) } - if details, err = ctx.GetUserProvider().GetDetails(username); err != nil { + if details, err = handleGetUserDetails(ctx, username, extended); err != nil { if errors.Is(err, authentication.ErrUserNotFound) { doMarkAuthenticationAttemptWithRequest(ctx, false, regulation.NewBan(regulation.BanTypeUnknown, "", nil), regulation.AuthType1FA, object.String(), object.Method, err) @@ -472,9 +511,27 @@ func handleGetBasic(ctx AuthzContext, delayer middlewares.Delayer, authn *Authn, return details, authentication.OneFactor, nil } +func handleGetUserDetails(ctx AuthzContext, username string, extended bool) (details *authentication.UserDetailsExtended, err error) { + if extended { + return ctx.GetUserProvider().GetDetailsExtended(username) + } + + var basic *authentication.UserDetails + + if basic, err = ctx.GetUserProvider().GetDetails(username); err != nil { + return nil, err + } + + if basic == nil { + return nil, nil + } + + return &authentication.UserDetailsExtended{UserDetails: basic}, nil +} + func handleAuthnCookieValidate(ctx AuthzContext, manager session.Manager, userSession *session.UserSession, refresh schema.RefreshIntervalDuration) (modified, invalid bool) { // TODO: Remove this check as it's no longer possible i.e. ineffectual. - isAnonymous := userSession.Username == "" + isAnonymous := userSession.IsAnonymous() if isAnonymous && userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA) != authentication.NotAuthenticated { ctx.GetLogger().WithFields(map[string]any{"username": anonymous, "level": userSession.AuthenticationLevel(ctx.GetConfiguration().WebAuthn.EnablePasskey2FA).String()}).Errorf("Session for user has an invalid authentication level: this may be a sign of a compromise") @@ -578,7 +635,7 @@ func handleSessionValidateRefresh(ctx AuthzContext, userSession *session.UserSes return true, false } -func handleVerifyGETAuthorizationBearer(ctx AuthzContext, authn *Authn, object *authorization.Object) (details *authentication.UserDetails, clientID string, ccs bool, level authentication.Level, err error) { +func handleVerifyGETAuthorizationBearer(ctx AuthzContext, authn *Authn, object *authorization.Object, extended bool) (details *authentication.UserDetailsExtended, clientID string, ccs bool, level authentication.Level, err error) { var at bool if at, err = oidc.IsAccessToken(ctx, authn.Header.Authorization.Value()); !at { @@ -597,15 +654,15 @@ func handleVerifyGETAuthorizationBearer(ctx AuthzContext, authn *Authn, object * return nil, "", false, authentication.NotAuthenticated, err } - return handleVerifyGETAuthorizationBearerResolveUser(ctx, username, clientID, ccs, level) + return handleVerifyGETAuthorizationBearerResolveUser(ctx, username, clientID, ccs, level, extended) } -func handleVerifyGETAuthorizationBearerResolveUser(ctx AuthzContext, username, clientID string, ccs bool, level authentication.Level) (details *authentication.UserDetails, clientIDOut string, ccsOut bool, levelOut authentication.Level, err error) { +func handleVerifyGETAuthorizationBearerResolveUser(ctx AuthzContext, username, clientID string, ccs bool, level authentication.Level, extended bool) (details *authentication.UserDetailsExtended, clientIDOut string, ccsOut bool, levelOut authentication.Level, err error) { if ccs { return nil, clientID, ccs, level, nil } - if details, err = ctx.GetUserProvider().GetDetails(username); err != nil { + if details, err = handleGetUserDetails(ctx, username, extended); err != nil { if errors.Is(err, authentication.ErrUserNotFound) { ctx.GetLogger().WithField("username", username).Error("Error occurred while attempting to get user details for user: the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login") } diff --git a/internal/handlers/handler_authz_builder.go b/internal/handlers/handler_authz_builder.go index 1a9abc974..5394f18c1 100644 --- a/internal/handlers/handler_authz_builder.go +++ b/internal/handlers/handler_authz_builder.go @@ -73,9 +73,18 @@ func (b *AuthzBuilder) WithConfig(config *schema.Configuration) *AuthzBuilder { return b } +// WithEndpointHeaders configures the AuthzBuilder with the response headers for this endpoint. +func (b *AuthzBuilder) WithEndpointHeaders(headers map[string]schema.ServerEndpointsAuthzHeader) *AuthzBuilder { + b.headers = headers + + return b +} + // WithEndpointConfig configures the AuthzBuilder with a *schema.ServerAuthzEndpointConfig. Should be called AFTER // WithConfig or WithAuthzConfig. func (b *AuthzBuilder) WithEndpointConfig(config schema.ServerEndpointsAuthz) *AuthzBuilder { + b.WithEndpointHeaders(config.Headers) + switch config.Implementation { case AuthzImplForwardAuth.String(): b.WithImplementationForwardAuth() @@ -129,9 +138,27 @@ func (b *AuthzBuilder) Build() (authz *Authz) { } } + for header, config := range b.headers { + authz.headers = append(authz.headers, AuthzHeader{Key: []byte(header), Attribute: config.UserAttribute}) + } + + extended := authzHeadersRequireExtendedUserDetails(authz.headers) + + for _, v := range authz.strategies { + switch strategy := v.(type) { + case *CookieSessionAuthnStrategy: + strategy.extended = extended + case *HeaderAuthnStrategy: + strategy.extended = extended + case *HeaderLegacyAuthnStrategy: + strategy.extended = extended + } + } + switch b.implementation { case AuthzImplLegacy: authz.config.StatusCodeBadRequest = fasthttp.StatusUnauthorized + authz.handleAuthorized = handleAuthzAuthorizedLegacy authz.handleGetObject = handleAuthzGetObjectLegacy authz.handleUnauthorized = handleAuthzUnauthorizedLegacy authz.handleGetAutheliaURL = handleAuthzPortalURLLegacy diff --git a/internal/handlers/handler_authz_common.go b/internal/handlers/handler_authz_common.go index ba41538b3..bf8354726 100644 --- a/internal/handlers/handler_authz_common.go +++ b/internal/handlers/handler_authz_common.go @@ -35,19 +35,40 @@ func handleAuthzPortalURLFromQueryLegacy(ctx AuthzContext) (portalURL *url.URL, return parseAuthzPortalURL(ctx.GetRequestQueryArgValue(qryArgRD)) } -func handleAuthzAuthorizedStandard(ctx AuthzContext, authn *Authn) { +func handleAuthzAuthorizedStandard(ctx AuthzContext, headers []AuthzHeader, authn *Authn) { ctx.ReplyStatusCode(fasthttp.StatusOK) - if authn.Details.Username != "" { - ctx.SetResponseHeaderValue(headerRemoteUser, authn.Details.Username) - ctx.SetResponseHeaderValue(headerRemoteGroups, strings.Join(authn.Details.Groups, ",")) - ctx.SetResponseHeaderValue(headerRemoteName, authn.Details.DisplayName) + if authn.Details.GetUsername() == "" { + return + } - switch len(authn.Details.Emails) { + resolver, updated := ctx.GetProviderUserAttributeResolver(), ctx.GetClock().Now() + + for _, header := range headers { + object, ok := resolver.Resolve(header.Attribute, authn.Details, updated) + if !ok { + ctx.SetResponseHeaderValue(header.Key, "") + + continue + } + + ctx.SetResponseHeaderValue(header.Key, authzHeaderValue(object)) + } +} + +func handleAuthzAuthorizedLegacy(ctx AuthzContext, _ []AuthzHeader, authn *Authn) { + ctx.ReplyStatusCode(fasthttp.StatusOK) + + if authn.Details.GetUsername() != "" { + ctx.SetResponseHeaderValue(headerRemoteUser, authn.Details.GetUsername()) + ctx.SetResponseHeaderValue(headerRemoteGroups, strings.Join(authn.Details.GetGroups(), ",")) + ctx.SetResponseHeaderValue(headerRemoteName, authn.Details.GetDisplayName()) + + switch emails := authn.Details.GetEmails(); len(emails) { case 0: ctx.SetResponseHeaderValue(headerRemoteEmail, "") default: - ctx.SetResponseHeaderValue(headerRemoteEmail, authn.Details.Emails[0]) + ctx.SetResponseHeaderValue(headerRemoteEmail, emails[0]) } } } diff --git a/internal/handlers/handler_authz_misc_test.go b/internal/handlers/handler_authz_misc_test.go index 60fe3583d..a763fc0b0 100644 --- a/internal/handlers/handler_authz_misc_test.go +++ b/internal/handlers/handler_authz_misc_test.go @@ -93,7 +93,7 @@ func TestHandleGetBasicShouldRejectEmptyCredentialsWithDelay(t *testing.T) { object := authorization.NewObject(targetURL, fasthttp.MethodGet) - details, level, err := handleGetBasic(mock.Ctx, delayer, authn, &object, headerProxyAuthorization, DefaultBasicAuthHandler) + details, level, err := handleGetBasic(mock.Ctx, delayer, authn, &object, headerProxyAuthorization, DefaultBasicAuthHandler, false) require.EqualError(t, err, tc.ExpectError) assert.Nil(t, details) @@ -120,8 +120,9 @@ func TestHandleVerifyGETAuthorizationBearerResolveUser(t *testing.T) { ClientID string CCS bool Level authentication.Level + Extended bool Setup func(mock *mocks.MockAutheliaCtx) - ExpectDetails *authentication.UserDetails + ExpectDetails *authentication.UserDetailsExtended ExpectError string }{ { @@ -132,6 +133,7 @@ func TestHandleVerifyGETAuthorizationBearerResolveUser(t *testing.T) { Level: authentication.OneFactor, Setup: func(mock *mocks.MockAutheliaCtx) { mock.UserProviderMock.EXPECT().GetDetails(gomock.Any()).Times(0) + mock.UserProviderMock.EXPECT().GetDetailsExtended(gomock.Any()).Times(0) }, ExpectDetails: nil, }, @@ -142,11 +144,27 @@ func TestHandleVerifyGETAuthorizationBearerResolveUser(t *testing.T) { CCS: false, Level: authentication.OneFactor, Setup: func(mock *mocks.MockAutheliaCtx) { + mock.UserProviderMock.EXPECT().GetDetailsExtended(gomock.Any()).Times(0) mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{Username: "john"}, nil) }, - ExpectDetails: &authentication.UserDetails{Username: "john"}, + ExpectDetails: &authentication.UserDetailsExtended{UserDetails: &authentication.UserDetails{Username: "john"}}, + }, + { + Name: "ShouldResolveExtendedDetailsForUserBoundTokenWhenHeadersRequireThem", + Username: "john", + ClientID: "", + CCS: false, + Level: authentication.OneFactor, + Extended: true, + Setup: func(mock *mocks.MockAutheliaCtx) { + mock.UserProviderMock.EXPECT().GetDetails(gomock.Any()).Times(0) + mock.UserProviderMock.EXPECT(). + GetDetailsExtended(gomock.Eq("john")). + Return(&authentication.UserDetailsExtended{GivenName: "John", UserDetails: &authentication.UserDetails{Username: "john"}}, nil) + }, + ExpectDetails: &authentication.UserDetailsExtended{GivenName: "John", UserDetails: &authentication.UserDetails{Username: "john"}}, }, { Name: "ShouldReturnErrorWhenGetDetailsFails", @@ -185,7 +203,7 @@ func TestHandleVerifyGETAuthorizationBearerResolveUser(t *testing.T) { tc.Setup(mock) } - details, clientID, ccs, level, err := handleVerifyGETAuthorizationBearerResolveUser(mock.Ctx, tc.Username, tc.ClientID, tc.CCS, tc.Level) + details, clientID, ccs, level, err := handleVerifyGETAuthorizationBearerResolveUser(mock.Ctx, tc.Username, tc.ClientID, tc.CCS, tc.Level, tc.Extended) if tc.ExpectError != "" { require.EqualError(t, err, tc.ExpectError) diff --git a/internal/handlers/handler_authz_test.go b/internal/handlers/handler_authz_test.go index 3edc17b85..c060fe0fb 100644 --- a/internal/handlers/handler_authz_test.go +++ b/internal/handlers/handler_authz_test.go @@ -22,6 +22,7 @@ import ( "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/expression" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/mocks" "github.com/authelia/authelia/v4/internal/model" @@ -91,11 +92,11 @@ func (s *AuthzSuite) Builder() (builder *AuthzBuilder) { switch s.implementation { case AuthzImplExtAuthz: - return NewAuthzBuilder().WithImplementationExtAuthz() + return NewAuthzBuilder().WithImplementationExtAuthz().WithEndpointHeaders(schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameExtAuthz].Headers) case AuthzImplForwardAuth: - return NewAuthzBuilder().WithImplementationForwardAuth() + return NewAuthzBuilder().WithImplementationForwardAuth().WithEndpointHeaders(schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameForwardAuth].Headers) case AuthzImplAuthRequest: - return NewAuthzBuilder().WithImplementationAuthRequest() + return NewAuthzBuilder().WithImplementationAuthRequest().WithEndpointHeaders(schema.DefaultServerConfiguration.Endpoints.Authz[schema.AuthzEndpointNameAuthRequest].Headers) case AuthzImplLegacy: return NewAuthzBuilder().WithImplementationLegacy() } @@ -1569,6 +1570,167 @@ func (s *AuthzSuite) TestShouldNotFailOnMissingEmail() { s.Equal("abc,123", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteGroups))) } +func (s *AuthzSuite) TestShouldSetHeadersFromExtendedUserAttributes() { + if s.setRequest == nil || s.implementation == AuthzImplLegacy { + s.T().Skip() + } + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + setUpMockClock(mock) + + authz := s.Builder().WithConfig(&mock.Ctx.Configuration).WithEndpointHeaders(map[string]schema.ServerEndpointsAuthzHeader{ + schema.HeaderRemoteUser: {UserAttribute: expression.AttributeUserUsername}, + "Remote-Given-Name": {UserAttribute: expression.AttributeUserGivenName}, + "Remote-Teams": {UserAttribute: "teams"}, + "Remote-Employee-Id": {UserAttribute: "employee_id"}, + "Remote-Contractor": {UserAttribute: "contractor"}, + "Remote-Unknown": {UserAttribute: "unknown"}, + }).Build() + + 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{"admins", "dev"} + userSession.Emails = []string{"john@example.com"} + userSession.AuthenticationMethodRefs.UsernameAndPassword = true + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + mock.UserProviderMock.EXPECT(). + GetDetailsExtended(gomock.Eq(testUsername)). + Return(&authentication.UserDetailsExtended{ + GivenName: "John", + UserDetails: &authentication.UserDetails{ + Username: testUsername, + DisplayName: "John Smith", + Groups: []string{"admins", "dev"}, + Emails: []string{"john@example.com"}, + }, + Extra: map[string]any{ + "teams": []any{"devs", "ops"}, + "employee_id": float64(10000000), + "contractor": false, + }, + }, nil).Times(1) + + 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", string(mock.Ctx.Response.Header.Peek("Remote-Given-Name"))) + s.Equal("devs,ops", string(mock.Ctx.Response.Header.Peek("Remote-Teams"))) + s.Equal("10000000", string(mock.Ctx.Response.Header.Peek("Remote-Employee-Id"))) + s.Equal("false", string(mock.Ctx.Response.Header.Peek("Remote-Contractor"))) + s.Equal("", string(mock.Ctx.Response.Header.Peek("Remote-Unknown"))) +} + +func (s *AuthzSuite) TestShouldNotAuthenticateWhenExtendedUserDetailsAreEmpty() { + if s.setRequest == nil || s.implementation == AuthzImplLegacy { + s.T().Skip() + } + + testCases := []struct { + name string + details *authentication.UserDetailsExtended + }{ + {"ShouldHandleNil", nil}, + {"ShouldHandleEmptyUsername", &authentication.UserDetailsExtended{UserDetails: &authentication.UserDetails{}}}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + setUpMockClock(mock) + + authz := s.Builder().WithConfig(&mock.Ctx.Configuration).WithEndpointHeaders(map[string]schema.ServerEndpointsAuthzHeader{ + schema.HeaderRemoteUser: {UserAttribute: expression.AttributeUserUsername}, + "Remote-Given-Name": {UserAttribute: expression.AttributeUserGivenName}, + }).Build() + + 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.AuthenticationMethodRefs.UsernameAndPassword = true + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + mock.UserProviderMock.EXPECT(). + GetDetailsExtended(gomock.Eq(testUsername)). + Return(tc.details, nil).Times(1) + + authz.Handler(mock.Ctx) + + s.Equal("", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteUser))) + s.Equal("", string(mock.Ctx.Response.Header.Peek("Remote-Given-Name"))) + + userSession, err = mock.Ctx.GetSession() + s.Require().NoError(err) + + s.Equal("", userSession.Username) + }) + } +} + +func (s *AuthzSuite) TestShouldNotRetrieveUserDetailsWhenHeadersOnlyRequireSessionAttributes() { + if s.setRequest == nil { + s.T().Skip() + } + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + setUpMockClock(mock) + + authz := s.Builder().WithConfig(&mock.Ctx.Configuration).Build() + + 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{"admins", "dev"} + userSession.Emails = []string{"john@example.com"} + userSession.AuthenticationMethodRefs.UsernameAndPassword = true + userSession.RefreshTTL = mock.Clock.Now().Add(5 * time.Minute) + + s.Require().NoError(mock.Ctx.SaveSession(userSession)) + + mock.UserProviderMock.EXPECT().GetDetails(gomock.Any()).Times(0) + mock.UserProviderMock.EXPECT().GetDetailsExtended(gomock.Any()).Times(0) + + 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("admins,dev", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteGroups))) + s.Equal("john@example.com", string(mock.Ctx.Response.Header.PeekBytes(headerRemoteEmail))) +} + func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomain() { if s.setRequest == nil { s.T().Skip() diff --git a/internal/handlers/handler_authz_types.go b/internal/handlers/handler_authz_types.go index f2870c943..6d570c4a7 100644 --- a/internal/handlers/handler_authz_types.go +++ b/internal/handlers/handler_authz_types.go @@ -34,13 +34,21 @@ type Authz struct { handleUnauthorized HandlerAuthzUnauthorized implementation AuthzImplementation + + headers []AuthzHeader +} + +// AuthzHeader represents a response header which is set from a resolved user attribute. +type AuthzHeader struct { + Key []byte + Attribute string } // HandlerAuthzUnauthorized is a Authz handler func that handles unauthorized responses. type HandlerAuthzUnauthorized func(ctx AuthzContext, authn *Authn, redirectionURL *url.URL) // HandlerAuthzAuthorized is a Authz handler func that handles authorized responses. -type HandlerAuthzAuthorized func(ctx AuthzContext, authn *Authn) +type HandlerAuthzAuthorized func(ctx AuthzContext, headers []AuthzHeader, authn *Authn) // HandlerAuthzGetAutheliaURL is a Authz handler func that handles retrieval of the Portal URL. type HandlerAuthzGetAutheliaURL func(ctx AuthzContext) (portalURL *url.URL, err error) @@ -77,7 +85,7 @@ type Authn struct { Method string ClientID string - Details authentication.UserDetails + Details *authentication.UserDetailsExtended Level authentication.Level Object authorization.Object Type AuthnType @@ -107,6 +115,7 @@ type AuthzBuilder struct { config AuthzConfig implementation AuthzImplementation strategies []AuthnStrategy + headers map[string]schema.ServerEndpointsAuthzHeader } // AuthnStrategy is a strategy used for Authz authentication. diff --git a/internal/handlers/handler_authz_util.go b/internal/handlers/handler_authz_util.go index 27bf6ad92..307c53dc8 100644 --- a/internal/handlers/handler_authz_util.go +++ b/internal/handlers/handler_authz_util.go @@ -7,15 +7,71 @@ package handlers import ( "fmt" "net/url" + "strconv" + "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/expression" "github.com/authelia/authelia/v4/internal/session" "github.com/authelia/authelia/v4/internal/utils" ) +var authzSessionUserAttributes = []string{ + expression.AttributeUserUsername, + expression.AttributeUserGroups, + expression.AttributeUserDisplayName, + expression.AttributeUserEmail, + expression.AttributeUserEmails, + expression.AttributeUserEmailsExtra, + expression.AttributeUserEmailVerified, + expression.AttributeUserUpdatedAt, +} + +func authzHeadersRequireExtendedUserDetails(headers []AuthzHeader) (extended bool) { + for _, header := range headers { + if !utils.IsStringInSlice(header.Attribute, authzSessionUserAttributes) { + return true + } + } + + return false +} + +func authzHeaderValue(object any) (value string) { + switch v := object.(type) { + case nil: + return "" + case string: + return v + case []string: + return strings.Join(v, ",") + case []any: + values := make([]string, len(v)) + + for i, item := range v { + values[i] = authzHeaderValue(item) + } + + return strings.Join(values, ",") + case bool: + return strconv.FormatBool(v) + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return fmt.Sprintf("%d", v) + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case time.Time: + return v.Format(time.RFC3339) + default: + return fmt.Sprintf("%v", v) + } +} + func friendlyMethod(m string) (fm string) { switch m { case "": diff --git a/internal/handlers/handler_authz_util_test.go b/internal/handlers/handler_authz_util_test.go index 8ed9706cb..84ea6102b 100644 --- a/internal/handlers/handler_authz_util_test.go +++ b/internal/handlers/handler_authz_util_test.go @@ -7,12 +7,15 @@ package handlers import ( "net/url" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/valyala/fasthttp" "github.com/authelia/authelia/v4/internal/authorization" + "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/expression" "github.com/authelia/authelia/v4/internal/mocks" ) @@ -372,3 +375,148 @@ func TestGetSafeAutheliaURL(t *testing.T) { }) } } + +func TestAuthzHeaderValue(t *testing.T) { + testCases := []struct { + Name string + Have any + Expected string + }{ + { + Name: "ShouldHandleNil", + Have: nil, + Expected: "", + }, + { + Name: "ShouldHandleString", + Have: "john", + Expected: "john", + }, + { + Name: "ShouldJoinStringSlice", + Have: []string{"admins", "dev"}, + Expected: "admins,dev", + }, + { + Name: "ShouldJoinEmptyStringSlice", + Have: []string{}, + Expected: "", + }, + { + Name: "ShouldJoinAnySliceOfStrings", + Have: []any{"admins", "dev"}, + Expected: "admins,dev", + }, + { + Name: "ShouldJoinAnySliceOfMixedValues", + Have: []any{"admins", 5, true, nil, 1.5}, + Expected: "admins,5,true,,1.5", + }, + { + Name: "ShouldHandleBool", + Have: false, + Expected: "false", + }, + { + Name: "ShouldHandleInt", + Have: 10000000, + Expected: "10000000", + }, + { + Name: "ShouldHandleInt64", + Have: int64(-5), + Expected: "-5", + }, + { + Name: "ShouldHandleUint", + Have: uint(5), + Expected: "5", + }, + { + Name: "ShouldHandleFloat64WithoutScientificNotation", + Have: float64(10000000), + Expected: "10000000", + }, + { + Name: "ShouldHandleFloat64WithFraction", + Have: 1.5, + Expected: "1.5", + }, + { + Name: "ShouldHandleFloat32", + Have: float32(1.5), + Expected: "1.5", + }, + { + Name: "ShouldHandleTime", + Have: time.Date(2026, 9, 12, 17, 0, 0, 0, time.UTC), + Expected: "2026-09-12T17:00:00Z", + }, + { + Name: "ShouldFallbackToDefaultFormatting", + Have: map[string]string{"abc": "123"}, + Expected: "map[abc:123]", + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + assert.Equal(t, tc.Expected, authzHeaderValue(tc.Have)) + }) + } +} + +func TestAuthzHeadersRequireExtendedUserDetails(t *testing.T) { + testCases := []struct { + Name string + Have []AuthzHeader + Expected bool + }{ + { + Name: "ShouldNotRequireExtendedForNoHeaders", + Have: nil, + Expected: false, + }, + { + Name: "ShouldNotRequireExtendedForDefaultHeaders", + Have: []AuthzHeader{ + {Key: []byte(schema.HeaderRemoteUser), Attribute: expression.AttributeUserUsername}, + {Key: []byte(schema.HeaderRemoteGroups), Attribute: expression.AttributeUserGroups}, + {Key: []byte(schema.HeaderRemoteName), Attribute: expression.AttributeUserDisplayName}, + {Key: []byte(schema.HeaderRemoteEmail), Attribute: expression.AttributeUserEmail}, + }, + Expected: false, + }, + { + Name: "ShouldNotRequireExtendedForOtherSessionAttributes", + Have: []AuthzHeader{ + {Key: []byte("Remote-Emails"), Attribute: expression.AttributeUserEmails}, + {Key: []byte("Remote-Emails-Extra"), Attribute: expression.AttributeUserEmailsExtra}, + {Key: []byte("Remote-Email-Verified"), Attribute: expression.AttributeUserEmailVerified}, + {Key: []byte("Remote-Updated-At"), Attribute: expression.AttributeUserUpdatedAt}, + }, + Expected: false, + }, + { + Name: "ShouldRequireExtendedForStandardExtendedAttribute", + Have: []AuthzHeader{ + {Key: []byte(schema.HeaderRemoteUser), Attribute: expression.AttributeUserUsername}, + {Key: []byte("Remote-Given-Name"), Attribute: expression.AttributeUserGivenName}, + }, + Expected: true, + }, + { + Name: "ShouldRequireExtendedForCustomAttribute", + Have: []AuthzHeader{ + {Key: []byte("Remote-Teams"), Attribute: "teams"}, + }, + Expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + assert.Equal(t, tc.Expected, authzHeadersRequireExtendedUserDetails(tc.Have)) + }) + } +} diff --git a/internal/middlewares/authelia_context.go b/internal/middlewares/authelia_context.go index b5dc05886..0f386b718 100644 --- a/internal/middlewares/authelia_context.go +++ b/internal/middlewares/authelia_context.go @@ -647,7 +647,7 @@ func (ctx *AutheliaCtx) SpecialRedirect(uri string, statusCode int) { u, statusCode = ctx.setSpecialRedirect(uri, statusCode) ctx.SetContentTypeTextHTML() - ctx.SetBodyString(fmt.Sprintf("%d %s", utils.StringHTMLEscape(string(u)), statusCode, fasthttp.StatusMessage(statusCode))) + ctx.SetBodyString(fmt.Sprintf(`%d %s`, utils.StringHTMLEscape(string(u)), statusCode, fasthttp.StatusMessage(statusCode))) } // SpecialRedirectNoBody performs a redirect similar to fasthttp.RequestCtx except it allows statusCode 401 and includes diff --git a/internal/mocks/authelia_context.go b/internal/mocks/authelia_context.go index c9a205390..ec02ddabe 100644 --- a/internal/mocks/authelia_context.go +++ b/internal/mocks/authelia_context.go @@ -23,6 +23,7 @@ import ( "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/clock" "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/expression" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/random" "github.com/authelia/authelia/v4/internal/regulation" @@ -213,6 +214,8 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx { providers.Authorizer = authorization.NewAuthorizer( &config) + providers.UserAttributeResolver = expression.NewUserAttributes(&config) + providers.SessionProvider = session.NewProvider(config.Session, nil) providers.Regulator = regulation.NewRegulator(config.Regulation, providers.StorageProvider, &mockAuthelia.Clock) diff --git a/internal/suites/Standalone/configuration.yml b/internal/suites/Standalone/configuration.yml index 15d19af04..7fb85f4a7 100644 --- a/internal/suites/Standalone/configuration.yml +++ b/internal/suites/Standalone/configuration.yml @@ -21,6 +21,28 @@ server: - 'session' - 'user' cache: '0' + authz: + legacy: + implementation: 'Legacy' + auth-request: + implementation: 'AuthRequest' + ext-authz: + implementation: 'ExtAuthz' + forward-auth: + implementation: 'ForwardAuth' + forward-auth-attributes: + implementation: 'ForwardAuth' + headers: + Remote-User: + user_attribute: 'username' + Remote-Groups: + user_attribute: 'groups' + Remote-Given-Name: + user_attribute: 'given_name' + Remote-Employee-Id: + user_attribute: 'employee_id' + Remote-Is-Admin: + user_attribute: 'is_admin' rate_limits: reset_password_start: buckets: @@ -95,6 +117,14 @@ session: authentication_backend: file: path: '/config/users.yml' + extra_attributes: + employee_id: + value_type: 'integer' + +definitions: + user_attributes: + is_admin: + expression: '"admins" in groups' totp: issuer: 'example.com' diff --git a/internal/suites/Standalone/users.yml b/internal/suites/Standalone/users.yml index 5b0bbd6e5..d83c1fef7 100644 --- a/internal/suites/Standalone/users.yml +++ b/internal/suites/Standalone/users.yml @@ -8,9 +8,12 @@ users: displayname: "John Doe" password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/" # yamllint disable-line rule:line-length email: john.doe@authelia.com + given_name: "John" groups: - admins - dev + extra: + employee_id: 1001 harry: displayname: "Harry Potter" diff --git a/internal/suites/suite_standalone_test.go b/internal/suites/suite_standalone_test.go index 0033fb820..561067ec5 100644 --- a/internal/suites/suite_standalone_test.go +++ b/internal/suites/suite_standalone_test.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "net/url" + "strings" "testing" "time" @@ -296,6 +297,82 @@ func (s *StandaloneSuite) TestShouldVerifyAPIVerifyRedirectFromXOriginalHostURI( s.Assert().Equal(fmt.Sprintf("302 Found", utils.StringHTMLEscape(fmt.Sprintf("%s/?rd=%s&rm=GET", GetLoginBaseURL(BaseDomain), urlEncodedAdminURL))), string(body)) } +func (s *StandaloneSuite) doAuthzBasicRequest(endpoint, username, password string) *http.Response { + req, err := http.NewRequest(fasthttp.MethodGet, fmt.Sprintf("%s/api/authz/%s", AutheliaBaseURL, endpoint), nil) + s.Require().NoError(err) + + req.Header.Set("X-Forwarded-Method", fasthttp.MethodGet) + req.Header.Set(fasthttp.HeaderXForwardedProto, "https") + req.Header.Set(fasthttp.HeaderXForwardedHost, fmt.Sprintf("singlefactor.%s", BaseDomain)) + req.Header.Set("X-Forwarded-URI", "/") + req.Header.Set(fasthttp.HeaderAccept, "text/html; charset=utf8") + + if username != "" { + req.SetBasicAuth(username, password) + } + + res, err := NewHTTPClient().Do(req) + s.Require().NoError(err) + + s.T().Cleanup(func() { + _ = res.Body.Close() + }) + + return res +} + +func (s *StandaloneSuite) TestShouldRespondWithDefaultAuthzHeaders() { + res := s.doAuthzBasicRequest("forward-auth", "john", "password") + + s.Require().Equal(fasthttp.StatusOK, res.StatusCode) + + s.Assert().Equal("john", res.Header.Get("Remote-User")) + s.Assert().ElementsMatch([]string{"admins", "dev"}, strings.Split(res.Header.Get("Remote-Groups"), ",")) + s.Assert().Equal("John Doe", res.Header.Get("Remote-Name")) + s.Assert().Equal("john.doe@authelia.com", res.Header.Get("Remote-Email")) + + s.Assert().Empty(res.Header.Values("Remote-Given-Name")) + s.Assert().Empty(res.Header.Values("Remote-Employee-Id")) + s.Assert().Empty(res.Header.Values("Remote-Is-Admin")) +} + +func (s *StandaloneSuite) TestShouldRespondWithConfiguredAuthzHeaders() { + res := s.doAuthzBasicRequest("forward-auth-attributes", "john", "password") + + s.Require().Equal(fasthttp.StatusOK, res.StatusCode) + + s.Assert().Equal("john", res.Header.Get("Remote-User")) + s.Assert().ElementsMatch([]string{"admins", "dev"}, strings.Split(res.Header.Get("Remote-Groups"), ",")) + s.Assert().Equal("John", res.Header.Get("Remote-Given-Name")) + s.Assert().Equal("1001", res.Header.Get("Remote-Employee-Id")) + s.Assert().Equal("true", res.Header.Get("Remote-Is-Admin")) + + s.Assert().Empty(res.Header.Values("Remote-Name")) + s.Assert().Empty(res.Header.Values("Remote-Email")) +} + +func (s *StandaloneSuite) TestShouldRespondWithConfiguredAuthzHeadersForUserWithoutAttributes() { + res := s.doAuthzBasicRequest("forward-auth-attributes", "harry", "password") + + s.Require().Equal(fasthttp.StatusOK, res.StatusCode) + + s.Assert().Equal("harry", res.Header.Get("Remote-User")) + s.Assert().Equal("", res.Header.Get("Remote-Groups")) + s.Assert().Equal("", res.Header.Get("Remote-Given-Name")) + s.Assert().Equal("", res.Header.Get("Remote-Employee-Id")) + s.Assert().Equal("false", res.Header.Get("Remote-Is-Admin")) +} + +func (s *StandaloneSuite) TestShouldNotRespondWithConfiguredAuthzHeadersWhenUnauthenticated() { + res := s.doAuthzBasicRequest("forward-auth-attributes", "", "") + + s.Assert().Equal(fasthttp.StatusFound, res.StatusCode) + + for _, header := range []string{"Remote-User", "Remote-Groups", "Remote-Given-Name", "Remote-Employee-Id", "Remote-Is-Admin"} { + s.Assert().Empty(res.Header.Values(header), header) + } +} + func (s *StandaloneSuite) TestShouldServeVerboseHealthCheck() { client := NewHTTPClient()