mirror of
https://github.com/authelia/authelia.git
synced 2026-10-28 09:34:52 +08:00
feat(handlers): derive authz response headers from user attributes (#9346)
Each authz endpoint takes a headers option mapping a response header name to a user attribute, so any standard, extended, or custom attribute can be sent to the proxy. Remote-User, Remote-Groups, Remote-Name and Remote-Email remain the default for every implementation except legacy. Extended user details are only retrieved when a configured attribute requires them, which is determined when the endpoint is built. Header names and the attributes they resolve are validated at startup. --------- Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com> Co-authored-by: Amir Zarrinkafsh <3339418+nightah@users.noreply.github.com>
This commit is contained in:
1 parent
177cb9e3ea
commit
3837cffa88
25 files changed
+1298
-76
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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" >}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "":
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -647,7 +647,7 @@ func (ctx *AutheliaCtx) SpecialRedirect(uri string, statusCode int) {
|
||||
u, statusCode = ctx.setSpecialRedirect(uri, statusCode)
|
||||
|
||||
ctx.SetContentTypeTextHTML()
|
||||
ctx.SetBodyString(fmt.Sprintf("<a href=\"%s\">%d %s</a>", utils.StringHTMLEscape(string(u)), statusCode, fasthttp.StatusMessage(statusCode)))
|
||||
ctx.SetBodyString(fmt.Sprintf(`<a href="%s">%d %s</a>`, utils.StringHTMLEscape(string(u)), statusCode, fasthttp.StatusMessage(statusCode)))
|
||||
}
|
||||
|
||||
// SpecialRedirectNoBody performs a redirect similar to fasthttp.RequestCtx except it allows statusCode 401 and includes
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("<a href=\"%s\">302 Found</a>", 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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user