Authelia Missing Username Canonicalization in Basic Auth (LDAP)
Description
Impact
CVSSv4 Baseline Score: Moderate 6.3
CVSSv4 Weighted Score: Low 2.9
The full CVSSv4 Vector for this vulnerability is:
> CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:P/CR:L/IR:L/AR:L/MAV:N/MAC:H/MAT:N/MPR:N/MUI:N/MVC:L/MVI:N/MVA:N/MSC:N/MSI:N/MSA:N/S:N/AU:Y/R:U/V:D/RE:L/U:Green
CVSSv3.1 Baseline Score: Low 3.7
CVSSv3.1 Overall Score: Medium 4.0
The full CVSSv3.1 Vector equivalent for this vulnerability is:
> CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N/E:P/RL:O/RC:X/CR:H/IR:L/AR:L/MAV:N/MAC:H/MPR:N/MUI:N/MS:U/MC:L/MI:N/MA:N
The weighted severity rating is a result of no indication this is currently being exploited being available at the time of the publish date, in addition to the fact it's unlikely that it is being exploited currently.
Due to lack of canonicalization of the basic auth username, the effectiveness of the brute force mechanism when using basic auth is partially degraded.
Most passwords of reasonable length are unlikely to have a meaningful effect due to the fact there is no clear feedback to an attacker that is attempting to exploit this, thus their brute force attempts are significantly more likely to miss a valid password than they are identify a valid one.
Details
When a user authenticates via Basic Auth (i.e via the Authorization header with the Basic scheme) on the authz verification endpoint, Authelia takes the username directly from the Authorization header and passes it as is to the regulation system for ban checking and attempt recording.
LDAP treats usernames case insensitively : john, John, and JOHN all bind as the same user. But the regulation SQL queries treat the lookup of these values in certain scenarios as case sensitive. This allows each variation of a usernames case to have its own ban bucket.
Notable conditions or unaffected configurations:
- The first factor login endpoint (
/api/firstfactor) is not affected - The LDAP authentication backend must be in use.
- If the underlying database is case insensitive (as it should be with the collation we use for MySQL) it is not affected
- Administrators using the recently added IP regulation mode are not affected
- Administrators using a third-party tool such as CrowdSec or fail2ban are not affected
- Administrators that have disabled basic auth are not affected
Patches
Upgrade to 4.39.20.
Commit: https://github.com/authelia/authelia/commit/b8985b57b70acdff8f204ed426ff619e763461ad
Workarounds
Explicitly disable the basic auth mechanism.
Caddy, HAProxy, and Traefik
server:
endpoints:
authz:
forward-auth:
implementation: 'ForwardAuth'
authn_strategies:
- name: 'CookieSession'
nginx
server:
endpoints:
authz:
auth-request:
implementation: 'AuthRequest'
authn_strategies:
- name: 'CookieSession'
Envoy
server:
endpoints:
authz:
ext-authz:
implementation: 'ExtAuthz'
authn_strategies:
- name: 'CookieSession'
References
N/A
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Missing username canonicalization in Authelia's Basic Auth handler allows case-variant usernames to bypass regulation bans when using an LDAP backend, degrading brute-force protection.
Vulnerability
In Authelia, the authorization verification endpoint (/authz) accepts Basic Authentication via the Authorization header. When an LDAP authentication backend is configured, the username from the header is passed directly to the regulation system for ban checking and attempt recording without canonicalization [2]. LDAP treats usernames case-insensitively (e.g., john, John, and JOHN bind as the same user), but the regulation SQL queries treat these values as case-sensitive in certain scenarios [2]. This mismatch allows each case variation of a username to have its own separate ban bucket. The vulnerability affects versions prior to the fix commit [1]; the first factor login endpoint (/api/firstfactor) is not affected [2].
Exploitation
An unauthenticated attacker with network access can send multiple Basic Auth requests to the authorization verification endpoint, systematically varying the case of the target username (e.g., john, John, JOHN, etc.) [2]. Because each variant is tracked independently against the regulation ban thresholds, the effective number of allowed password guesses per real user is multiplied by the number of case permutations. However, there is no clear feedback to the attacker indicating which variant corresponds to the valid password, and the attack complexity is considered high due to the lack of discernible responses [2].
Impact
The vulnerability partially degrades the effectiveness of Authelia's brute-force protection mechanism when Basic Auth is used with an LDAP backend [2]. The CVSS assessment indicates a Low confidentiality impact (VC:L) with no integrity or availability impact [2]. The weighted severity is rated Low because there is no evidence of active exploitation and the practical benefit to an attacker is limited [2]. The attacker does not gain direct access to resources or user credentials through this flaw alone.
Mitigation
A fix was committed to the Authelia repository in commit b8985b57b70acdff8f204ed426ff619e763461ad, which canonicalizes the Basic Auth username before passing it to the regulation system [1]. Users should update their Authelia deployment to the latest version that includes this commit [1]. There are no known workarounds for this vulnerability; the remedy requires applying the patch. The advisory does not list this CVE on CISA's Known Exploited Vulnerabilities (KEV) catalog.
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
1b8985b57b70afix(handlers): basic auth username canonicalization [security] (#12170)
21 files changed · +1396 −715
internal/handlers/const.go+0 −1 modified@@ -60,7 +60,6 @@ var ( var ( qryValueBasic = []byte("basic") - qryValueEmpty = []byte("") ) const (
internal/handlers/handler_authz_authn.go+140 −137 modified@@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/sha256" - "encoding/base64" "errors" "fmt" "net/url" @@ -19,6 +18,7 @@ import ( "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/configuration/schema" + "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/oidc" "github.com/authelia/authelia/v4/internal/regulation" @@ -43,6 +43,7 @@ func NewHeaderAuthorizationAuthnStrategy(schemaBasicCacheLifeSpan time.Duration, handleAuthenticate: true, statusAuthenticate: fasthttp.StatusUnauthorized, schemes: model.NewAuthorizationSchemes(schemes...), + delay: middlewares.NewTimingAttackDelay(50, time.Second*2).SetSuccessDelay(false).SetRecord(true).SetMinimumDelayDuration(time.Second * 2), basic: NewBasicAuthHandler(schemaBasicCacheLifeSpan), } } @@ -57,6 +58,7 @@ func NewHeaderProxyAuthorizationAuthnStrategy(schemaBasicCacheLifeSpan time.Dura handleAuthenticate: true, statusAuthenticate: fasthttp.StatusProxyAuthRequired, schemes: model.NewAuthorizationSchemes(schemes...), + delay: middlewares.NewTimingAttackDelay(50, time.Second*2).SetSuccessDelay(false).SetRecord(true).SetMinimumDelayDuration(time.Second * 2), basic: NewBasicAuthHandler(schemaBasicCacheLifeSpan), } } @@ -72,13 +74,16 @@ func NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(schemaBasicCacheLifeSpa handleAuthenticate: true, statusAuthenticate: fasthttp.StatusUnauthorized, schemes: model.NewAuthorizationSchemes(schemes...), + delay: middlewares.NewTimingAttackDelay(50, time.Second*2).SetSuccessDelay(false).SetRecord(true).SetMinimumDelayDuration(time.Second * 2), basic: NewBasicAuthHandler(schemaBasicCacheLifeSpan), } } // NewHeaderLegacyAuthnStrategy creates a new HeaderLegacyAuthnStrategy. func NewHeaderLegacyAuthnStrategy() *HeaderLegacyAuthnStrategy { - return &HeaderLegacyAuthnStrategy{} + return &HeaderLegacyAuthnStrategy{ + delay: middlewares.NewTimingAttackDelay(50, time.Second*2).SetSuccessDelay(false).SetRecord(true).SetMinimumDelayDuration(time.Second * 2), + } } // CookieSessionAuthnStrategy is a session cookie AuthnStrategy. @@ -169,6 +174,7 @@ type HeaderAuthnStrategy struct { statusAuthenticate int schemes model.AuthorizationSchemes + delay middlewares.Delayer basic BasicAuthHandler } @@ -186,7 +192,7 @@ func NewBasicAuthHandler(lifespan time.Duration) BasicAuthHandler { // DefaultBasicAuthHandler is a BasicAuthHandler that just checks the username and password directly. func DefaultBasicAuthHandler(ctx AuthzContext, authorization *model.Authorization) (valid, cached bool, err error) { - valid, err = ctx.GetProviders().UserProvider.CheckUserPassword(authorization.Basic()) + valid, err = ctx.GetUserProvider().CheckUserPassword(authorization.Basic()) return valid, false, err } @@ -226,7 +232,7 @@ func (s *HeaderAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, object *a authn.Header.Authorization = authz var ( - username, clientID string + clientID string ccs bool level authentication.Level @@ -242,11 +248,13 @@ func (s *HeaderAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, object *a return authn, nil } + var details *authentication.UserDetails + switch scheme { case model.AuthorizationSchemeBasic: - username, level, err = s.handleGetBasic(ctx, authn, object) + details, level, err = handleGetBasic(ctx, s.delay, authn, object, s.headerAuthorize, s.basic) case model.AuthorizationSchemeBearer: - username, clientID, ccs, level, err = handleVerifyGETAuthorizationBearer(ctx, authn, object) + details, clientID, ccs, level, err = handleVerifyGETAuthorizationBearer(ctx, authn, object) default: ctx.GetLogger(). WithFields(map[string]any{"scheme": authn.Header.Authorization.SchemeRaw(), "header": string(s.headerAuthorize)}). @@ -270,21 +278,11 @@ func (s *HeaderAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, object *a } authn.ClientID = clientID - case len(username) == 0: + case details == nil: + return authn, fmt.Errorf("failed to determine user identity from the %s header", s.headerAuthorize) + case len(details.Username) == 0: return authn, fmt.Errorf("failed to determine username from the %s header", s.headerAuthorize) default: - var details *authentication.UserDetails - - if details, err = ctx.GetProviders().UserProvider.GetDetails(username); 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") - - return authn, err - } - - return authn, fmt.Errorf("unable to retrieve details for user '%s': %w", username, err) - } - authn.Username = friendlyUsername(details.Username) authn.Details = *details } @@ -294,52 +292,6 @@ func (s *HeaderAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, object *a return authn, nil } -func (s *HeaderAuthnStrategy) handleGetBasic(ctx AuthzContext, authn *Authn, object *authorization.Object) (username string, level authentication.Level, err error) { - var ( - ban regulation.BanType - value string - expires *time.Time - ) - - username = authn.Header.Authorization.BasicUsername() - - if ban, value, expires, err = ctx.GetProviders().Regulator.BanCheck(ctx, username); err != nil { - if errors.Is(err, regulation.ErrUserIsBanned) { - doMarkAuthenticationAttemptWithRequest(ctx, false, regulation.NewBan(ban, value, expires), regulation.AuthType1FA, object.String(), object.Method, nil) - - return "", authentication.NotAuthenticated, fmt.Errorf("failed to validate the credentials of user '%s' parsed from the %s header: %w", username, s.headerAuthorize, err) - } - - ctx.GetLogger().WithError(err).Errorf(logFmtErrRegulationFail, regulation.AuthType1FA, username) - - return "", authentication.NotAuthenticated, fmt.Errorf("failed to check the regulation status of user '%s' during an attempt to authenticate using the %s header: %w", username, s.headerAuthorize, err) - } - - var valid, cached bool - - if valid, cached, err = s.basic(ctx, authn.Header.Authorization); err != nil { - if isRegulatorSkippedErr(err) { - ctx.GetLogger().WithError(err).Errorf("Unsuccessful %s authentication attempt by user '%s'", regulation.AuthType1FA, authn.Header.Authorization.BasicUsername()) - } else { - doMarkAuthenticationAttemptWithRequest(ctx, false, regulation.NewBan(regulation.BanTypeNone, username, nil), regulation.AuthType1FA, object.String(), object.Method, err) - } - - return "", authentication.NotAuthenticated, fmt.Errorf("failed to validate the credentials of user '%s' parsed from the %s header: %w", username, s.headerAuthorize, err) - } - - if !valid { - doMarkAuthenticationAttemptWithRequest(ctx, false, regulation.NewBan(regulation.BanTypeNone, username, nil), regulation.AuthType1FA, object.String(), object.Method, nil) - - return "", authentication.NotAuthenticated, fmt.Errorf("failed to validate parsed credentials of %s header valid for user '%s': the username and password do not match", s.headerAuthorize, username) - } - - if !cached { - doMarkAuthenticationAttemptWithRequest(ctx, true, regulation.NewBan(regulation.BanTypeNone, username, nil), regulation.AuthType1FA, object.String(), object.Method, nil) - } - - return username, authentication.OneFactor, nil -} - // CanHandleUnauthorized returns true if this AuthnStrategy should handle Unauthorized requests. func (s *HeaderAuthnStrategy) CanHandleUnauthorized() (handle bool) { return s.handleAuthenticate @@ -364,13 +316,14 @@ 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{} +type HeaderLegacyAuthnStrategy struct { + delay middlewares.Delayer +} // Get returns the Authn information for this AuthnStrategy. -func (s *HeaderLegacyAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, _ *authorization.Object) (authn *Authn, err error) { +func (s *HeaderLegacyAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, object *authorization.Object) (authn *Authn, err error) { var ( - username, password string - value, header []byte + value, header []byte ) authn = &Authn{ @@ -386,49 +339,59 @@ func (s *HeaderLegacyAuthnStrategy) Get(ctx AuthzContext, _ session.Manager, _ * header = headerProxyAuthorization } - value = ctx.GetRequestHeaderValue(header) + if value = ctx.GetRequestHeaderValue(header); len(value) == 0 { + if authn.Type == AuthnTypeAuthorization { + return authn, fmt.Errorf("header %s expected", headerAuthorization) + } - switch { - case value == nil && authn.Type == AuthnTypeAuthorization: - return authn, fmt.Errorf("header %s expected", headerAuthorization) - case value == nil: return authn, nil } - if username, password, err = headerAuthorizationParse(value); err != nil { + authz := model.NewAuthorization() + + if err = authz.ParseBytes(value); err != nil { return authn, fmt.Errorf("failed to parse content of %s header: %w", header, err) } - if username == "" || password == "" { - return authn, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s': %w", header, username, err) + authn.Header.Authorization = authz + + scheme := authn.Header.Authorization.Scheme() + + switch scheme { + case model.AuthorizationSchemeBasic: + break + default: + ctx.GetLogger(). + WithFields(map[string]any{"scheme": authn.Header.Authorization.SchemeRaw(), "header": string(header)}). + Debug("Skipping header authorization as the scheme is unknown to this endpoint configuration") + + return authn, fmt.Errorf("header is malformed: unsupported scheme '%s': supported schemes '%s'", scheme, strings.ToTitle(headerAuthorizationSchemeBasic)) } var ( - valid bool details *authentication.UserDetails + level authentication.Level ) - if valid, err = ctx.GetProviders().UserProvider.CheckUserPassword(username, password); err != nil { - return authn, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s': %w", header, username, err) - } + validate := func(ctx AuthzContext, authz *model.Authorization) (valid, cached bool, err error) { + username, password := authn.Header.Authorization.Basic() - if !valid { - return authn, fmt.Errorf("validated parsed credentials of %s header but they are not valid for user '%s': %w", header, username, err) - } + if username == "" || password == "" { + return false, false, fmt.Errorf("failed to validate parsed credentials of %s header for user '%s'", header, username) + } - if details, err = ctx.GetProviders().UserProvider.GetDetails(username); 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") + valid, err = ctx.GetUserProvider().CheckUserPassword(username, password) - return authn, err - } + return valid, false, err + } - return authn, fmt.Errorf("unable to retrieve details for user '%s': %w", username, err) + if details, level, err = handleGetBasic(ctx, s.delay, authn, object, header, validate); 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.Level = authentication.OneFactor + authn.Level = level return authn, nil } @@ -448,6 +411,63 @@ 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) { + var ( + ban regulation.BanType + value string + expires *time.Time + valid, cached bool + ) + + started := ctx.GetClock().Now() + + defer delayer.CachedDelay(ctx, started, &cached, &valid) + + username := authn.Header.Authorization.BasicUsername() + + if details, err = ctx.GetUserProvider().GetDetails(username); 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") + } + + return nil, authentication.NotAuthenticated, fmt.Errorf("failed to retrieve user details for user %s: %w", username, err) + } + + if ban, value, expires, err = ctx.GetProviders().Regulator.BanCheck(ctx, details.Username); err != nil { + if errors.Is(err, regulation.ErrUserIsBanned) { + doMarkAuthenticationAttemptWithRequest(ctx, false, regulation.NewBan(ban, value, expires), regulation.AuthType1FA, object.String(), object.Method, nil) + + return nil, authentication.NotAuthenticated, fmt.Errorf("failed to validate the credentials of user '%s' parsed from the %s header: %w", details.Username, header, err) + } + + ctx.GetLogger().WithError(err).Errorf(logFmtErrRegulationFail, regulation.AuthType1FA, details.Username) + + return nil, authentication.NotAuthenticated, fmt.Errorf("failed to check the regulation status of user '%s' during an attempt to authenticate using the %s header: %w", details.Username, header, err) + } + + if valid, cached, err = validate(ctx, authn.Header.Authorization); err != nil { + if isRegulatorSkippedErr(err) { + ctx.GetLogger().WithError(err).Errorf("Unsuccessful %s authentication attempt by user '%s'", regulation.AuthType1FA, authn.Header.Authorization.BasicUsername()) + } else { + doMarkAuthenticationAttemptWithRequest(ctx, false, regulation.NewBan(regulation.BanTypeNone, details.Username, nil), regulation.AuthType1FA, object.String(), object.Method, err) + } + + return nil, authentication.NotAuthenticated, fmt.Errorf("failed to validate the credentials of user '%s' parsed from the %s header: %w", details.Username, header, err) + } + + if !valid { + doMarkAuthenticationAttemptWithRequest(ctx, false, regulation.NewBan(regulation.BanTypeNone, details.Username, nil), regulation.AuthType1FA, object.String(), object.Method, nil) + + return nil, authentication.NotAuthenticated, fmt.Errorf("failed to validate parsed credentials of %s header valid for user '%s': the username and password do not match", header, details.Username) + } + + if !cached { + doMarkAuthenticationAttemptWithRequest(ctx, true, regulation.NewBan(regulation.BanTypeNone, details.Username, nil), regulation.AuthType1FA, object.String(), object.Method, nil) + } + + return details, authentication.OneFactor, 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 == "" @@ -512,7 +532,7 @@ func handleSessionValidateRefresh(ctx AuthzContext, userSession *session.UserSes details *authentication.UserDetails err error ) - if details, err = ctx.GetProviders().UserProvider.GetDetails(userSession.Username); err != nil { + if details, err = ctx.GetUserProvider().GetDetails(userSession.Username); err != nil { if errors.Is(err, authentication.ErrUserNotFound) { ctx.GetLogger().WithField("username", userSession.Username).Error("Error occurred while attempting to update user details for user: the user was not found indicating they were deleted, disabled, or otherwise no longer authorized to login") @@ -554,7 +574,7 @@ func handleSessionValidateRefresh(ctx AuthzContext, userSession *session.UserSes return true, false } -func handleVerifyGETAuthorizationBearer(ctx AuthzContext, authn *Authn, object *authorization.Object) (username, clientID string, ccs bool, level authentication.Level, err error) { +func handleVerifyGETAuthorizationBearer(ctx AuthzContext, authn *Authn, object *authorization.Object) (details *authentication.UserDetails, clientID string, ccs bool, level authentication.Level, err error) { var at bool if at, err = oidc.IsAccessToken(ctx, authn.Header.Authorization.Value()); !at { @@ -564,10 +584,35 @@ func handleVerifyGETAuthorizationBearer(ctx AuthzContext, authn *Authn, object * ctx.GetLogger().Debug("The bearer token does not appear to be a relevant access token") } - return "", "", false, authentication.NotAuthenticated, errTokenIntent + return nil, "", false, authentication.NotAuthenticated, errTokenIntent } - return handleVerifyGETAuthorizationBearerIntrospection(ctx, ctx.GetProviders().OpenIDConnect, authn, object) + var username string + + if username, clientID, ccs, level, err = handleVerifyGETAuthorizationBearerIntrospection(ctx, ctx.GetProviders().OpenIDConnect, authn, object); err != nil { + return nil, "", false, authentication.NotAuthenticated, err + } + + return handleVerifyGETAuthorizationBearerResolveUser(ctx, username, clientID, ccs, level) +} + +// handleVerifyGETAuthorizationBearerResolveUser turns the result of bearer-token introspection into the final return +// values for handleVerifyGETAuthorizationBearer. For client-credentials grants (ccs=true) there is no associated user +// so GetDetails is skipped and the clientID is propagated; for user-bound tokens GetDetails canonicalises the username. +func handleVerifyGETAuthorizationBearerResolveUser(ctx AuthzContext, username, clientID string, ccs bool, level authentication.Level) (details *authentication.UserDetails, 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 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") + } + + return nil, "", false, authentication.NotAuthenticated, fmt.Errorf("failed to retrieve user details for user %s: %w", username, err) + } + + return details, clientID, ccs, level, nil } func handleVerifyGETAuthorizationBearerIntrospection(ctx context.Context, provider AuthzBearerIntrospectionProvider, authn *Authn, object *authorization.Object) (username, clientID string, ccs bool, level authentication.Level, err error) { @@ -639,45 +684,3 @@ func handleVerifyGETAuthorizationBearerIntrospection(ctx context.Context, provid return osession.Username, "", false, level, nil } - -func headerAuthorizationParse(value []byte) (username, password string, err error) { - if bytes.Equal(value, qryValueEmpty) { - return "", "", fmt.Errorf("header is malformed: empty value") - } - - parts := strings.SplitN(string(value), " ", 2) - - if len(parts) != 2 { - return "", "", fmt.Errorf("header is malformed: does not appear to have a scheme") - } - - scheme := strings.ToLower(parts[0]) - - switch scheme { - case headerAuthorizationSchemeBasic: - if username, password, err = headerAuthorizationParseBasic(parts[1]); err != nil { - return username, password, fmt.Errorf("header is malformed: %w", err) - } - - return username, password, nil - default: - return "", "", fmt.Errorf("header is malformed: unsupported scheme '%s': supported schemes '%s'", parts[0], strings.ToTitle(headerAuthorizationSchemeBasic)) - } -} - -func headerAuthorizationParseBasic(value string) (username, password string, err error) { - var content []byte - - if content, err = base64.StdEncoding.DecodeString(value); err != nil { - return "", "", fmt.Errorf("could not decode credentials: %w", err) - } - - strContent := string(content) - s := strings.IndexByte(strContent, ':') - - if s < 1 { - return "", "", fmt.Errorf("format of header must be <user>:<password> but either doesn't have a colon or username") - } - - return strContent[:s], strContent[s+1:], nil -}
internal/handlers/handler_authz.go+4 −0 modified@@ -5,6 +5,7 @@ import ( "fmt" "net" "net/url" + "time" "github.com/golang-jwt/jwt/v5" "github.com/sirupsen/logrus" @@ -131,6 +132,9 @@ type AuthzContext interface { // RecordAuthn should record the authentication of the user. RecordAuthn(success, banned bool, authType string) + // RecordAuthenticationDuration should record the time taken by the user to perform an authentication attempt. + RecordAuthenticationDuration(success bool, elapsed time.Duration) + // RemoteIP Should return the remote IP of the request. RemoteIP() net.IP
internal/handlers/handler_authz_impl_authrequest_test.go+13 −13 modified@@ -45,7 +45,7 @@ func (s *AuthRequestAuthzSuite) TestShouldHandleAllMethodsDeny() { t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -79,7 +79,7 @@ func (s *AuthRequestAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -105,7 +105,7 @@ func (s *AuthRequestAuthzSuite) TestShouldHandleMissingXOriginalMethodDeny() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { s.T().Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -124,7 +124,7 @@ func (s *AuthRequestAuthzSuite) TestShouldHandleMissingXOriginalMethodDeny() { func (s *AuthRequestAuthzSuite) TestShouldHandleMissingXOriginalURLDeny() { for _, method := range testRequestMethods { s.T().Run(fmt.Sprintf("OriginalMethod%s", method), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -150,7 +150,7 @@ func (s *AuthRequestAuthzSuite) TestShouldHandleAllMethodsAllow() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -173,7 +173,7 @@ func (s *AuthRequestAuthzSuite) TestShouldHandleAllMethodsWithMethodsACL() { for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -228,7 +228,7 @@ func (s *AuthRequestAuthzSuite) TestShouldHandleInvalidURLForCVE202132637() { s.T().Run(tc.name, func(t *testing.T) { for _, method := range testRequestMethods { t.Run(fmt.Sprintf("OriginalMethod%s", method), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -260,7 +260,7 @@ func (s *AuthRequestAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsAllow() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -289,7 +289,7 @@ func (s *AuthRequestAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsAllowXHR() s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -314,7 +314,7 @@ func (s *AuthRequestAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsWithMethods for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -341,7 +341,7 @@ func (s *AuthRequestAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsAllow() s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -370,7 +370,7 @@ func (s *AuthRequestAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsAllowXHR s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -395,7 +395,7 @@ func (s *AuthRequestAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsWithMeth for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close()
internal/handlers/handler_authz_impl_extauthz_test.go+15 −15 modified@@ -45,7 +45,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsDeny() { t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -85,7 +85,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsOverrideAutheliaURLDeny() t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -124,7 +124,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsMissingAutheliaURLDeny() s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -159,7 +159,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsXHRDeny() { t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -198,7 +198,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -218,7 +218,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { func (s *ExtAuthzAuthzSuite) TestShouldHandleMissingHostDeny() { for _, method := range testRequestMethods { s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -243,7 +243,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsAllow() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -272,7 +272,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsAllowXHR() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -297,7 +297,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleAllMethodsWithMethodsACL() { for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -358,7 +358,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldHandleInvalidURLForCVE202132637() { s.T().Run(tc.name, func(t *testing.T) { for _, method := range testRequestMethods { t.Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -393,7 +393,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldNotHandleAuthRequestAllMethodsAllow() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -417,7 +417,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldNotHandleAuthRequestAllMethodsWithMethods for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -444,7 +444,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsAllow() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -473,7 +473,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsAllowXHR() s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -498,7 +498,7 @@ func (s *ExtAuthzAuthzSuite) TestShouldNotHandleForwardAuthAllMethodsWithMethods for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close()
internal/handlers/handler_authz_impl_forwardauth_test.go+15 −15 modified@@ -45,7 +45,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsDeny() { t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -85,7 +85,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsOverrideAutheliaURLDen t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -124,7 +124,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsMissingAutheliaURLDeny s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -159,7 +159,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsXHRDeny() { t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -196,7 +196,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -216,7 +216,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { func (s *ForwardAuthAuthzSuite) TestShouldHandleMissingHostDeny() { for _, method := range testRequestMethods { s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -245,7 +245,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsAllow() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() @@ -268,7 +268,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsWithMethodsACL() { for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -319,7 +319,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleAllMethodsAllowXHR() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -358,7 +358,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldHandleInvalidURLForCVE202132637() { s.T().Run(tc.name, func(t *testing.T) { for _, method := range testRequestMethods { t.Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -393,7 +393,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldNotHandleAuthRequestAllMethodsAllow() s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -417,7 +417,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldNotHandleAuthRequestAllMethodsWithMeth for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -445,7 +445,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsAllow() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -475,7 +475,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsAllowXHR() s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -501,7 +501,7 @@ func (s *ForwardAuthAuthzSuite) TestShouldNotHandleExtAuthzAllMethodsWithMethods for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t)
internal/handlers/handler_authz_impl_legacy_test.go+125 −28 modified@@ -16,6 +16,8 @@ import ( "github.com/authelia/authelia/v4/internal/authorization" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/mocks" + "github.com/authelia/authelia/v4/internal/model" + "github.com/authelia/authelia/v4/internal/regulation" "github.com/authelia/authelia/v4/internal/utils" ) @@ -48,7 +50,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsDeny() { t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -94,7 +96,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsOverrideAutheliaURLDeny() { t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -138,7 +140,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsMissingAutheliaURLBypassSta s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -170,7 +172,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsMissingAutheliaURLOneFactor s.RequireParseRequestURI("https://one-factor.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -200,7 +202,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsRDAutheliaURLOneFactorStatu s.RequireParseRequestURI("https://one-factor.example.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -247,7 +249,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsXHRDeny() { t.Run(pairURI.TargetURI.String(), func(t *testing.T) { expected := s.RequireParseRequestURI(pairURI.AutheliaURI.String()) - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -294,7 +296,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -319,7 +321,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleInvalidMethodCharsDeny() { func (s *LegacyAuthzSuite) TestShouldHandleMissingHostDeny() { for _, method := range testRequestMethods { s.T().Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -349,7 +351,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsAllow() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -377,7 +379,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsWithMethodsACL() { for _, methodACL := range testRequestMethods { targetURI := s.RequireParseRequestURI(fmt.Sprintf("https://bypass-%s.example.com", strings.ToLower(methodACL))) t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -429,7 +431,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsAllowXHR() { s.RequireParseRequestURI("https://bypass.example2.com/subpath"), } { t.Run(targetURI.String(), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t) @@ -452,27 +454,49 @@ func (s *LegacyAuthzSuite) TestShouldHandleAllMethodsAllowXHR() { } func (s *LegacyAuthzSuite) TestShouldHandleLegacyBasicAuth() { // TestShouldVerifyAuthBasicArgOk. - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) defer mock.Close() + setUpMockClock(mock) + mock.Ctx.QueryArgs().Add("auth", "basic") mock.Ctx.Request.Header.Set(fasthttp.HeaderAuthorization, "Basic am9objpwYXNzd29yZA==") mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") - gomock.InOrder( - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil), + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: true, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://one-factor.example.com", + RequestMethod: fasthttp.MethodGet, + } + gomock.InOrder( mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil), + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil), + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), ) authz.Handler(mock.Ctx) @@ -502,45 +526,118 @@ func (s *LegacyAuthzSuite) TestShouldHandleLegacyBasicAuthFailures() { }, }, { - "IncorrectPassword", // TestShouldVerifyAuthBasicArgFailingWrongPassword. + "UnsupportedScheme", + func(mock *mocks.MockAutheliaCtx) { + mock.Ctx.Request.Header.Set(fasthttp.HeaderAuthorization, "Bearer some-token") + }, + }, + { + "UserNotFound", func(mock *mocks.MockAutheliaCtx) { mock.Ctx.Request.Header.Set(fasthttp.HeaderAuthorization, "Basic am9objpwYXNzd29yZA==") mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, fmt.Errorf("generic error")) + GetDetails(gomock.Eq("john")). + Return(nil, authentication.ErrUserNotFound) }, }, { - "NoAccess", // TestShouldVerifyAuthBasicArgFailingWrongPassword. + "GetDetailsError", func(mock *mocks.MockAutheliaCtx) { mock.Ctx.Request.Header.Set(fasthttp.HeaderAuthorization, "Basic am9objpwYXNzd29yZA==") - mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com/") + + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(nil, fmt.Errorf("backend unreachable")) + }, + }, + { + "IncorrectPassword", // TestShouldVerifyAuthBasicArgFailingWrongPassword. + func(mock *mocks.MockAutheliaCtx) { + mock.Ctx.Request.Header.Set(fasthttp.HeaderAuthorization, "Basic am9objpwYXNzd29yZA==") + + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: false, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://one-factor.example.com", + RequestMethod: fasthttp.MethodGet, + } gomock.InOrder( + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{Username: "john"}, nil), + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), mock.UserProviderMock.EXPECT(). CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil), + Return(false, fmt.Errorf("generic error")), + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), + ) + }, + }, + { + "NoAccess", // TestShouldVerifyAuthBasicArgFailingWrongPassword. + func(mock *mocks.MockAutheliaCtx) { + mock.Ctx.Request.Header.Set(fasthttp.HeaderAuthorization, "Basic am9objpwYXNzd29yZA==") + mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com/") + + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: true, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://admin.example.com/", + RequestMethod: fasthttp.MethodGet, + } + gomock.InOrder( mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admin"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admin"}, }, nil), + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil), + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), ) }, }, } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() for _, tc := range testCases { s.T().Run(tc.name, func(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() + setUpMockClock(mock) + mock.Ctx.QueryArgs().Add("auth", "basic") mock.Ctx.Request.Header.Set("X-Original-URL", "https://one-factor.example.com") @@ -579,7 +676,7 @@ func (s *LegacyAuthzSuite) TestShouldHandleInvalidURLForCVE202132637() { s.T().Run(tc.name, func(t *testing.T) { for _, method := range testRequestMethods { t.Run(fmt.Sprintf("Method%s", method), func(t *testing.T) { - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(t)
internal/handlers/handler_authz_misc_test.go+112 −0 modified@@ -1,12 +1,16 @@ package handlers import ( + "fmt" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/valyala/fasthttp" + "go.uber.org/mock/gomock" "github.com/authelia/authelia/v4/internal/authentication" + "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/mocks" "github.com/authelia/authelia/v4/internal/session" ) @@ -21,6 +25,114 @@ func TestFriendlyMethod(t *testing.T) { assert.Equal(t, "GET", friendlyMethod(fasthttp.MethodGet)) } +func TestCookieSessionAuthnStrategyFlags(t *testing.T) { + strategy := NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways()) + + assert.False(t, strategy.CanHandleUnauthorized()) + assert.False(t, strategy.HeaderStrategy()) + + mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + strategy.HandleUnauthorized(mock.Ctx, &Authn{}, nil) + + assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + assert.Equal(t, []byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) +} + +func TestHandleVerifyGETAuthorizationBearerResolveUser(t *testing.T) { + testCases := []struct { + Name string + Username string + ClientID string + CCS bool + Level authentication.Level + Setup func(mock *mocks.MockAutheliaCtx) + ExpectDetails *authentication.UserDetails + ExpectError string + }{ + { + Name: "ShouldReturnClientIDForClientCredentialsWithoutCallingGetDetails", + Username: "", + ClientID: "client-abc", + CCS: true, + Level: authentication.OneFactor, + Setup: func(mock *mocks.MockAutheliaCtx) { + mock.UserProviderMock.EXPECT().GetDetails(gomock.Any()).Times(0) + }, + ExpectDetails: nil, + }, + { + Name: "ShouldResolveDetailsForUserBoundToken", + Username: "john", + ClientID: "", + CCS: false, + Level: authentication.OneFactor, + Setup: func(mock *mocks.MockAutheliaCtx) { + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{Username: "john"}, nil) + }, + ExpectDetails: &authentication.UserDetails{Username: "john"}, + }, + { + Name: "ShouldReturnErrorWhenGetDetailsFails", + Username: "ghost", + ClientID: "", + CCS: false, + Level: authentication.OneFactor, + Setup: func(mock *mocks.MockAutheliaCtx) { + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("ghost")). + Return(nil, fmt.Errorf("boom")) + }, + ExpectError: "failed to retrieve user details for user ghost: boom", + }, + { + Name: "ShouldReturnErrorWhenUserNotFound", + Username: "missing", + ClientID: "", + CCS: false, + Level: authentication.OneFactor, + Setup: func(mock *mocks.MockAutheliaCtx) { + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("missing")). + Return(nil, authentication.ErrUserNotFound) + }, + ExpectError: "failed to retrieve user details for user missing: user not found", + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + if tc.Setup != nil { + tc.Setup(mock) + } + + details, clientID, ccs, level, err := handleVerifyGETAuthorizationBearerResolveUser(mock.Ctx, tc.Username, tc.ClientID, tc.CCS, tc.Level) + + if tc.ExpectError != "" { + require.EqualError(t, err, tc.ExpectError) + assert.Nil(t, details) + assert.Empty(t, clientID) + assert.False(t, ccs) + assert.Equal(t, authentication.NotAuthenticated, level) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.ExpectDetails, details) + assert.Equal(t, tc.ClientID, clientID) + assert.Equal(t, tc.CCS, ccs) + assert.Equal(t, tc.Level, level) + }) + } +} + func TestGenerateVerifySessionHasUpToDateProfileTraceLogs(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t)
internal/handlers/handler_authz_test.go+792 −437 modified@@ -1,6 +1,7 @@ package handlers import ( + "database/sql" "fmt" "net/url" "testing" @@ -11,13 +12,19 @@ import ( "github.com/valyala/fasthttp" "go.uber.org/mock/gomock" + oauthelia2 "authelia.com/provider/oauth2" + "authelia.com/provider/oauth2/handler/openid" + fjwt "authelia.com/provider/oauth2/token/jwt" + "github.com/authelia/authelia/v4/internal/authentication" "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/mocks" "github.com/authelia/authelia/v4/internal/model" + "github.com/authelia/authelia/v4/internal/oidc" "github.com/authelia/authelia/v4/internal/regulation" "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/storage" "github.com/authelia/authelia/v4/internal/utils" ) @@ -81,16 +88,48 @@ func (s *AuthzSuite) Builder() (builder *AuthzBuilder) { return } +func (s *AuthzSuite) BuildWithDelayer() (authz *Authz) { + authz = s.Builder().Build() + + s.ApplyTestDelayer(authz) + + return authz +} + +func (s *AuthzSuite) ApplyTestDelayer(authz *Authz) { + for i, v := range authz.strategies { + switch strategy := v.(type) { + case *HeaderAuthnStrategy: + strategy.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + authz.strategies[i] = strategy + case *HeaderLegacyAuthnStrategy: + strategy.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + authz.strategies[i] = strategy + } + } +} + func (s *AuthzSuite) BuilderWithBearerScheme() (builder *AuthzBuilder) { + proxyHeader := NewHeaderProxyAuthorizationAuthnStrategy(time.Duration(0), model.AuthorizationSchemeBasic.String(), model.AuthorizationSchemeBearer.String()) + proxyHeader.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + proxyAuthHeader := NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Duration(0), model.AuthorizationSchemeBasic.String(), model.AuthorizationSchemeBearer.String()) + proxyAuthHeader.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + legacyHeader := NewHeaderLegacyAuthnStrategy() + legacyHeader.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + switch s.implementation { case AuthzImplExtAuthz: - return NewAuthzBuilder().WithImplementationExtAuthz().WithStrategies(NewHeaderProxyAuthorizationAuthnStrategy(time.Duration(0), model.AuthorizationSchemeBasic.String(), model.AuthorizationSchemeBearer.String()), NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) + return NewAuthzBuilder().WithImplementationExtAuthz().WithStrategies(proxyHeader, NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) case AuthzImplForwardAuth: - return NewAuthzBuilder().WithImplementationForwardAuth().WithStrategies(NewHeaderProxyAuthorizationAuthnStrategy(time.Duration(0), model.AuthorizationSchemeBasic.String(), model.AuthorizationSchemeBearer.String()), NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) + return NewAuthzBuilder().WithImplementationForwardAuth().WithStrategies(proxyHeader, NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) case AuthzImplAuthRequest: - return NewAuthzBuilder().WithImplementationAuthRequest().WithStrategies(NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Duration(0), model.AuthorizationSchemeBasic.String(), model.AuthorizationSchemeBearer.String()), NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) + return NewAuthzBuilder().WithImplementationAuthRequest().WithStrategies(proxyAuthHeader, NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) case AuthzImplLegacy: - return NewAuthzBuilder().WithImplementationLegacy().WithStrategies(NewHeaderLegacyAuthnStrategy(), NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) + return NewAuthzBuilder().WithImplementationLegacy().WithStrategies(legacyHeader, NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) default: s.T().FailNow() } @@ -99,15 +138,24 @@ func (s *AuthzSuite) BuilderWithBearerScheme() (builder *AuthzBuilder) { } func (s *AuthzSuite) BuilderWithProxyAuthorizationBasicSchemeCached() (builder *AuthzBuilder) { + proxyHeader := NewHeaderProxyAuthorizationAuthnStrategy(time.Minute, model.AuthorizationSchemeBasic.String(), model.AuthorizationSchemeBearer.String()) + proxyHeader.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + proxyAuthHeader := NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Minute, model.AuthorizationSchemeBasic.String(), model.AuthorizationSchemeBearer.String()) + proxyAuthHeader.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + legacyHeader := NewHeaderLegacyAuthnStrategy() + legacyHeader.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + switch s.implementation { case AuthzImplExtAuthz: - return NewAuthzBuilder().WithImplementationExtAuthz().WithStrategies(NewHeaderProxyAuthorizationAuthnStrategy(time.Minute, model.AuthorizationSchemeBasic.String()), NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) + return NewAuthzBuilder().WithImplementationExtAuthz().WithStrategies(proxyHeader, NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) case AuthzImplForwardAuth: - return NewAuthzBuilder().WithImplementationForwardAuth().WithStrategies(NewHeaderProxyAuthorizationAuthnStrategy(time.Minute, model.AuthorizationSchemeBasic.String()), NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) + return NewAuthzBuilder().WithImplementationForwardAuth().WithStrategies(proxyHeader, NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) case AuthzImplAuthRequest: - return NewAuthzBuilder().WithImplementationAuthRequest().WithStrategies(NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Minute, model.AuthorizationSchemeBasic.String()), NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) + return NewAuthzBuilder().WithImplementationAuthRequest().WithStrategies(proxyAuthHeader, NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) case AuthzImplLegacy: - return NewAuthzBuilder().WithImplementationLegacy().WithStrategies(NewHeaderLegacyAuthnStrategy(), NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) + return NewAuthzBuilder().WithImplementationLegacy().WithStrategies(legacyHeader, NewCookieSessionAuthnStrategy(schema.NewRefreshIntervalDurationAlways())) default: s.T().FailNow() } @@ -120,7 +168,7 @@ func (s *AuthzSuite) TestShouldNotBeAbleToParseBasicAuth() { s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) @@ -151,7 +199,7 @@ func (s *AuthzSuite) TestShouldApplyDefaultPolicy() { s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) @@ -165,41 +213,36 @@ func (s *AuthzSuite) TestShouldApplyDefaultPolicy() { mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - switch s.implementation { - case AuthzImplLegacy: - break - default: - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://test.example.com", - RequestMethod: fasthttp.MethodGet, - } + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: true, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://test.example.com", + RequestMethod: fasthttp.MethodGet, } + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + mock.UserProviderMock. EXPECT(). CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")).Return(true, nil) mock.UserProviderMock. EXPECT(). - GetDetails(gomock.Eq("john")).Return(&authentication.UserDetails{Emails: []string{"john@example.com"}, Groups: []string{"dev", "admins"}}, nil) + GetDetails(gomock.Eq("john")).Return(&authentication.UserDetails{Username: "john", Emails: []string{"john@example.com"}, Groups: []string{"dev", "admins"}}, nil) authz.Handler(mock.Ctx) @@ -227,7 +270,7 @@ func (s *AuthzSuite) TestShouldDenyObject() { }, } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() for _, tc := range testCases { s.T().Run(tc.name, func(t *testing.T) { @@ -256,7 +299,7 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfBypassDomain() { s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) @@ -270,43 +313,39 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfBypassDomain() { mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - switch s.implementation { - case AuthzImplLegacy: - break - default: - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://bypass.example.com", - RequestMethod: fasthttp.MethodGet, - } + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: true, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://bypass.example.com", + RequestMethod: fasthttp.MethodGet, } + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + mock.UserProviderMock.EXPECT(). CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). Return(true, nil) mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil) authz.Handler(mock.Ctx) @@ -321,7 +360,7 @@ func (s *AuthzSuite) TestShouldVerifyFailureToGetDetailsUsingBasicScheme() { s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) @@ -335,38 +374,6 @@ func (s *AuthzSuite) TestShouldVerifyFailureToGetDetailsUsingBasicScheme() { mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - switch s.implementation { - case AuthzImplLegacy: - break - default: - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) - - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://one-factor.example.com", - RequestMethod: fasthttp.MethodGet, - } - - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) - } - - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil) - mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(nil, fmt.Errorf("generic failure")) @@ -404,45 +411,11 @@ func (s *AuthzSuite) TestShouldVerifyFailureToGetDetailsUsingBasicSchemeCached() mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://one-factor.example.com", - RequestMethod: fasthttp.MethodGet, - } - - if s.implementation == AuthzImplLegacy { - gomock.InOrder( - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil), - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(nil, fmt.Errorf("generic failure")), - ) - } else { - gomock.InOrder( - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil), - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(nil, fmt.Errorf("generic failure")), - ) - } + gomock.InOrder( + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(nil, fmt.Errorf("generic failure")), + ) authz.Handler(mock.Ctx) @@ -464,28 +437,11 @@ func (s *AuthzSuite) TestShouldVerifyFailureToGetDetailsUsingBasicSchemeCached() mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - if s.implementation == AuthzImplLegacy { - gomock.InOrder( - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil), - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(nil, fmt.Errorf("generic failure")), - ) - } else { - gomock.InOrder( - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), - mock.UserProviderMock.EXPECT(). - GetDetails(gomock.Eq("john")). - Return(nil, fmt.Errorf("generic failure")), - ) - } + gomock.InOrder( + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(nil, fmt.Errorf("generic failure")), + ) authz.Handler(mock.Ctx) @@ -531,28 +487,23 @@ func (s *AuthzSuite) TestShouldVerifyFailureToCheckPasswordUsingBasicSchemeCache RequestMethod: fasthttp.MethodGet, } - if s.implementation == AuthzImplLegacy { - gomock.InOrder( - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, nil), - ) - } else { - gomock.InOrder( - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, nil), - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), - ) - } + gomock.InOrder( + mock.UserProviderMock. + EXPECT(). + GetDetails(gomock.Eq("john")).Return(&authentication.UserDetails{Username: "john"}, nil), + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(false, nil), + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), + ) authz.Handler(mock.Ctx) @@ -574,28 +525,23 @@ func (s *AuthzSuite) TestShouldVerifyFailureToCheckPasswordUsingBasicSchemeCache mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - if s.implementation == AuthzImplLegacy { - gomock.InOrder( - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, nil), - ) - } else { - gomock.InOrder( - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, nil), - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), - ) - } + gomock.InOrder( + mock.UserProviderMock. + EXPECT(). + GetDetails(gomock.Eq("john")).Return(&authentication.UserDetails{Username: "john"}, nil), + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(false, nil), + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), + ) authz.Handler(mock.Ctx) @@ -641,28 +587,23 @@ func (s *AuthzSuite) TestShouldVerifyErrorToCheckPasswordUsingBasicSchemeCached( RequestMethod: fasthttp.MethodGet, } - if s.implementation == AuthzImplLegacy { - gomock.InOrder( - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, fmt.Errorf("bad data")), - ) - } else { - gomock.InOrder( - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, fmt.Errorf("bad data")), - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), - ) - } + gomock.InOrder( + mock.UserProviderMock. + EXPECT(). + GetDetails(gomock.Eq("john")).Return(&authentication.UserDetails{Username: "john"}, nil), + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(false, fmt.Errorf("bad data")), + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), + ) authz.Handler(mock.Ctx) @@ -684,28 +625,84 @@ func (s *AuthzSuite) TestShouldVerifyErrorToCheckPasswordUsingBasicSchemeCached( mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - if s.implementation == AuthzImplLegacy { - gomock.InOrder( - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, fmt.Errorf("bad data")), - ) - } else { - gomock.InOrder( - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(false, fmt.Errorf("bad data")), - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), - ) + gomock.InOrder( + mock.UserProviderMock. + EXPECT(). + GetDetails(gomock.Eq("john")).Return(&authentication.UserDetails{Username: "john"}, nil), + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(false, fmt.Errorf("bad data")), + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), + ) + + authz.Handler(mock.Ctx) + + switch s.implementation { + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) + default: + s.Equal(fasthttp.StatusProxyAuthRequired, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate))) } +} + +func (s *AuthzSuite) TestShouldRejectBannedUserUsingBasicScheme() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.BuildWithDelayer() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + setUpMockClock(mock) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + + expires := mock.Ctx.Providers.Clock.Now().Add(time.Minute) + + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: false, + Banned: true, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://one-factor.example.com", + RequestMethod: fasthttp.MethodGet, + } + + gomock.InOrder( + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{Username: "john"}, nil), + mock.StorageMock.EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))). + Return(nil, nil), + mock.StorageMock.EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")). + Return([]model.BannedUser{{ID: 1, Username: "john", Expires: sql.NullTime{Time: expires, Valid: true}}}, nil), + mock.StorageMock.EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)). + Return(nil), + ) authz.Handler(mock.Ctx) @@ -721,65 +718,192 @@ func (s *AuthzSuite) TestShouldVerifyErrorToCheckPasswordUsingBasicSchemeCached( } } -func (s *AuthzSuite) TestShouldVerifyBypassWithErrorToGetDetailsUsingBasicScheme() { +func (s *AuthzSuite) TestShouldRejectBannedIPUsingBasicScheme() { if s.setRequest == nil { s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) defer mock.Close() setUpMockClock(mock) - targetURI := s.RequireParseRequestURI("https://bypass.example.com") + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + expires := mock.Ctx.Providers.Clock.Now().Add(time.Minute) + + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: false, + Banned: true, + Username: model.NewIP(mock.Ctx.RemoteIP()).String(), + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://one-factor.example.com", + RequestMethod: fasthttp.MethodGet, + } + + gomock.InOrder( + mock.UserProviderMock.EXPECT(). + GetDetails(gomock.Eq("john")). + Return(&authentication.UserDetails{Username: "john"}, nil), + mock.StorageMock.EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))). + Return([]model.BannedIP{{ID: 1, IP: model.NewIP(mock.Ctx.RemoteIP()), Expires: sql.NullTime{Time: expires, Valid: true}}}, nil), + mock.StorageMock.EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)). + Return(nil), + ) + + authz.Handler(mock.Ctx) + switch s.implementation { - case AuthzImplLegacy: - break + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) default: - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) + s.Equal(fasthttp.StatusProxyAuthRequired, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate))) + } +} - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) +func (s *AuthzSuite) TestShouldRejectBannedCanonicalUserUsingBasicScheme() { + if s.setRequest == nil { + s.T().Skip() + } - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://bypass.example.com", - RequestMethod: fasthttp.MethodGet, - } + authz := s.BuildWithDelayer() - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + setUpMockClock(mock) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic Sk9ITjpwYXNzd29yZA==") + + expires := mock.Ctx.Providers.Clock.Now().Add(time.Minute) + + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: false, + Banned: true, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://one-factor.example.com", + RequestMethod: fasthttp.MethodGet, } gomock.InOrder( mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil), + GetDetails(gomock.Eq("JOHN")). + Return(&authentication.UserDetails{Username: "john"}, nil), + mock.StorageMock.EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))). + Return(nil, nil), + mock.StorageMock.EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")). + Return([]model.BannedUser{{ID: 1, Username: "john", Expires: sql.NullTime{Time: expires, Valid: true}}}, nil), + mock.StorageMock.EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)). + Return(nil), + ) + + authz.Handler(mock.Ctx) + + switch s.implementation { + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) + default: + s.Equal(fasthttp.StatusProxyAuthRequired, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate))) + } +} + +func (s *AuthzSuite) TestShouldHandleBanCheckStorageErrorUsingBasicScheme() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.BuildWithDelayer() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + setUpMockClock(mock) + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + gomock.InOrder( mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). - Return(nil, fmt.Errorf("generic failure")), + Return(&authentication.UserDetails{Username: "john"}, nil), + mock.StorageMock.EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))). + Return(nil, fmt.Errorf("database unreachable")), ) authz.Handler(mock.Ctx) + switch s.implementation { + case AuthzImplAuthRequest, AuthzImplLegacy: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate))) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) + default: + s.Equal(fasthttp.StatusProxyAuthRequired, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal(`Basic realm="Authorization Required"`, string(mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate))) + } +} + +func (s *AuthzSuite) TestShouldVerifyBypassWithErrorToGetDetailsUsingBasicScheme() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.BuildWithDelayer() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + setUpMockClock(mock) + + targetURI := s.RequireParseRequestURI("https://bypass.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + + mock.UserProviderMock. + EXPECT(). + GetDetails(gomock.Eq("john")).Return(nil, fmt.Errorf("generic failure")) + + authz.Handler(mock.Ctx) + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) } @@ -788,7 +912,7 @@ func (s *AuthzSuite) TestShouldVerifyBypassWithErrorToGetDetailsUsingBearerSchem s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) @@ -805,79 +929,272 @@ func (s *AuthzSuite) TestShouldVerifyBypassWithErrorToGetDetailsUsingBearerSchem s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) } -func (s *AuthzSuite) TestShouldVerifyBypassWithErrorToGetDetailsUsingBearerSchemePossibleToken() { +func (s *AuthzSuite) TestShouldVerifyBypassWithErrorToGetDetailsUsingBearerSchemePossibleToken() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.BuildWithDelayer() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + targetURI := s.RequireParseRequestURI("https://bypass.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Bearer authelia_at_aaaa.aaaaaa") + + authz.Handler(mock.Ctx) + + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) +} + +func (s *AuthzSuite) TestShouldVerifyOneFactorWithErrorToGetDetailsUsingBearerScheme() { + if s.setRequest == nil { + s.T().Skip() + } + + authz := s.BuilderWithBearerScheme().Build() + + mock := mocks.NewMockAutheliaCtx(s.T()) + + defer mock.Close() + + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) + + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Bearer am9objpwYXNzd29yZA==") + + authz.Handler(mock.Ctx) + + switch s.implementation { + case AuthzImplExtAuthz, AuthzImplForwardAuth: + s.Equal(fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + } +} + +func (s *AuthzSuite) TestShouldVerifyOneFactorWithErrorToGetDetailsUsingBearerSchemePossibleToken() { if s.setRequest == nil { s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuilderWithBearerScheme().Build() mock := mocks.NewMockAutheliaCtx(s.T()) defer mock.Close() - targetURI := s.RequireParseRequestURI("https://bypass.example.com") + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Bearer authelia_at_aaaa.aaaaaa") authz.Handler(mock.Ctx) - s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + switch s.implementation { + case AuthzImplExtAuthz, AuthzImplForwardAuth: + s.Equal(fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) + default: + s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + } } -func (s *AuthzSuite) TestShouldVerifyOneFactorWithErrorToGetDetailsUsingBearerScheme() { - if s.setRequest == nil { +func (s *AuthzSuite) TestShouldAuthenticateAsClientUsingBearerSchemeClientCredentials() { + if s.setRequest == nil || s.implementation == AuthzImplLegacy { s.T().Skip() } authz := s.BuilderWithBearerScheme().Build() + s.ApplyTestDelayer(authz) + mock := mocks.NewMockAutheliaCtx(s.T()) defer mock.Close() + setUpMockClock(mock) + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + audience := []string{"https://one-factor.example.com", "https://one-factor.example.com/"} + + mock.Ctx.Configuration.IdentityProviders = schema.IdentityProviders{ + OIDC: &schema.IdentityProvidersOpenIDConnect{ + HMACSecret: "abcdefghijklmnopqrstuvwxyz123456", + Discovery: schema.IdentityProvidersOpenIDConnectDiscovery{ + BearerAuthorization: true, + }, + Clients: []schema.IdentityProvidersOpenIDConnectClient{ + { + ID: "test-ccs-client", + Scopes: []string{oidc.ScopeAutheliaBearerAuthz}, + Audience: audience, + GrantTypes: []string{oidc.GrantTypeClientCredentials}, + AuthorizationPolicy: "one_factor", + }, + }, + }, + } + + mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(&mock.Ctx.Configuration, mock.StorageMock, mock.Ctx.Providers.Templates) + + client, err := mock.Ctx.Providers.OpenIDConnect.GetRegisteredClient(mock.Ctx, "test-ccs-client") + s.Require().NoError(err) + + now := mock.Ctx.Providers.Clock.Now() + + session := &oidc.Session{ + ClientID: "test-ccs-client", + ClientCredentials: true, + DefaultSession: &openid.DefaultSession{ + Headers: &fjwt.Headers{Extra: map[string]any{}}, + Claims: &fjwt.IDTokenClaims{ + Issuer: "https://auth.example.com", + Subject: "test-ccs-client", + IssuedAt: fjwt.NewNumericDate(now), + Extra: map[string]any{}, + }, + RequestedAt: now, + }, + } + + requester := &oauthelia2.AccessRequest{ + GrantTypes: oauthelia2.Arguments{oidc.GrantTypeClientCredentials}, + Request: oauthelia2.Request{ + ID: "request-ccs", + RequestedAt: now, + Client: client, + RequestedScope: oauthelia2.Arguments{oidc.ScopeAutheliaBearerAuthz}, + GrantedScope: oauthelia2.Arguments{oidc.ScopeAutheliaBearerAuthz}, + RequestedAudience: oauthelia2.Arguments(audience), + GrantedAudience: oauthelia2.Arguments(audience), + Session: session, + Form: url.Values{}, + }, + } + + token, signature, err := mock.Ctx.Providers.OpenIDConnect.Strategy.Core.GenerateAccessToken(mock.Ctx, requester) + s.Require().NoError(err) + + oauthSession, err := model.NewOAuth2SessionFromRequest(signature, requester) + s.Require().NoError(err) + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) - mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Bearer am9objpwYXNzd29yZA==") + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Bearer "+token) + + mock.StorageMock.EXPECT(). + LoadOAuth2Session(gomock.Eq(mock.Ctx), gomock.Eq(storage.OAuth2SessionTypeAccessToken), gomock.Eq(signature)). + Return(oauthSession, nil) authz.Handler(mock.Ctx) - switch s.implementation { - case AuthzImplExtAuthz, AuthzImplForwardAuth: - s.Equal(fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) - default: - s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) - } + s.Equal(fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderWWWAuthenticate)) + s.Equal([]byte(nil), mock.Ctx.Response.Header.Peek(fasthttp.HeaderProxyAuthenticate)) } -func (s *AuthzSuite) TestShouldVerifyOneFactorWithErrorToGetDetailsUsingBearerSchemePossibleToken() { - if s.setRequest == nil { +func (s *AuthzSuite) TestShouldRejectBearerSchemeClientCredentialsWithoutBearerAuthzScope() { + if s.setRequest == nil || s.implementation == AuthzImplLegacy { s.T().Skip() } authz := s.BuilderWithBearerScheme().Build() + s.ApplyTestDelayer(authz) + mock := mocks.NewMockAutheliaCtx(s.T()) defer mock.Close() + setUpMockClock(mock) + targetURI := s.RequireParseRequestURI("https://one-factor.example.com") + audience := []string{"https://one-factor.example.com", "https://one-factor.example.com/"} + + mock.Ctx.Configuration.IdentityProviders = schema.IdentityProviders{ + OIDC: &schema.IdentityProvidersOpenIDConnect{ + HMACSecret: "abcdefghijklmnopqrstuvwxyz123456", + Discovery: schema.IdentityProvidersOpenIDConnectDiscovery{ + BearerAuthorization: true, + }, + Clients: []schema.IdentityProvidersOpenIDConnectClient{ + { + ID: "test-ccs-client", + Scopes: []string{"openid"}, + Audience: audience, + GrantTypes: []string{oidc.GrantTypeClientCredentials}, + AuthorizationPolicy: "one_factor", + }, + }, + }, + } + + mock.Ctx.Providers.OpenIDConnect = oidc.NewOpenIDConnectProvider(&mock.Ctx.Configuration, mock.StorageMock, mock.Ctx.Providers.Templates) + + client, err := mock.Ctx.Providers.OpenIDConnect.GetRegisteredClient(mock.Ctx, "test-ccs-client") + s.Require().NoError(err) + + now := mock.Ctx.Providers.Clock.Now() + + session := &oidc.Session{ + ClientID: "test-ccs-client", + ClientCredentials: true, + DefaultSession: &openid.DefaultSession{ + Headers: &fjwt.Headers{Extra: map[string]any{}}, + Claims: &fjwt.IDTokenClaims{ + Issuer: "https://auth.example.com", + Subject: "test-ccs-client", + IssuedAt: fjwt.NewNumericDate(now), + Extra: map[string]any{}, + }, + RequestedAt: now, + }, + } + + requester := &oauthelia2.AccessRequest{ + GrantTypes: oauthelia2.Arguments{oidc.GrantTypeClientCredentials}, + Request: oauthelia2.Request{ + ID: "request-ccs-noauthz", + RequestedAt: now, + Client: client, + RequestedScope: oauthelia2.Arguments{"openid"}, + GrantedScope: oauthelia2.Arguments{"openid"}, + RequestedAudience: oauthelia2.Arguments(audience), + GrantedAudience: oauthelia2.Arguments(audience), + Session: session, + Form: url.Values{}, + }, + } + + token, signature, err := mock.Ctx.Providers.OpenIDConnect.Strategy.Core.GenerateAccessToken(mock.Ctx, requester) + s.Require().NoError(err) + + oauthSession, err := model.NewOAuth2SessionFromRequest(signature, requester) + s.Require().NoError(err) + s.setRequest(mock.Ctx, fasthttp.MethodGet, targetURI, true, false) - mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Bearer authelia_at_aaaa.aaaaaa") + mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Bearer "+token) + + mock.StorageMock.EXPECT(). + LoadOAuth2Session(gomock.Eq(mock.Ctx), gomock.Eq(storage.OAuth2SessionTypeAccessToken), gomock.Eq(signature)). + Return(oauthSession, nil) authz.Handler(mock.Ctx) switch s.implementation { - case AuthzImplExtAuthz, AuthzImplForwardAuth: - s.Equal(fasthttp.StatusFound, mock.Ctx.Response.StatusCode()) - default: + case AuthzImplAuthRequest, AuthzImplLegacy: s.Equal(fasthttp.StatusUnauthorized, mock.Ctx.Response.StatusCode()) + default: + s.Equal(fasthttp.StatusProxyAuthRequired, mock.Ctx.Response.StatusCode()) } } @@ -923,7 +1240,7 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomain() { s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) @@ -937,43 +1254,39 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomain() { mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - switch s.implementation { - case AuthzImplLegacy: - break - default: - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://one-factor.example.com", - RequestMethod: fasthttp.MethodGet, - } + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: true, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://one-factor.example.com", + RequestMethod: fasthttp.MethodGet, } + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + mock.UserProviderMock.EXPECT(). CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). Return(true, nil) mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil) authz.Handler(mock.Ctx) @@ -1002,40 +1315,45 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomainCached() { mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: true, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://one-factor.example.com", + RequestMethod: fasthttp.MethodGet, + } + if s.implementation == AuthzImplLegacy { gomock.InOrder( - mock.UserProviderMock.EXPECT(). - CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). - Return(true, nil), mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil), + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), mock.UserProviderMock.EXPECT(). CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). Return(true, nil), + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil), - ) - } else { - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://one-factor.example.com", - RequestMethod: fasthttp.MethodGet, - } - - gomock.InOrder( mock.StorageMock. EXPECT(). LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), @@ -1048,24 +1366,41 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomainCached() { mock.StorageMock. EXPECT(). AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), + ) + } else { + gomock.InOrder( mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil), mock.StorageMock. EXPECT(). LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), mock.StorageMock. EXPECT(). LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), + mock.UserProviderMock.EXPECT(). + CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). + Return(true, nil), + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil), mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil), + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil), + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil), ) } @@ -1103,7 +1438,7 @@ func (s *AuthzSuite) TestShouldHandleAnyCaseSchemeParameter() { {"MixedCase", "BaSIc"}, } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() for _, tc := range testCases { s.T().Run(tc.name, func(t *testing.T) { @@ -1119,43 +1454,39 @@ func (s *AuthzSuite) TestShouldHandleAnyCaseSchemeParameter() { mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, fmt.Sprintf("%s am9objpwYXNzd29yZA==", tc.scheme)) - switch s.implementation { - case AuthzImplLegacy: - break - default: - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) - - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://one-factor.example.com", - RequestMethod: fasthttp.MethodGet, - } - - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) + + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) + + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: true, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://one-factor.example.com", + RequestMethod: fasthttp.MethodGet, } + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + mock.UserProviderMock.EXPECT(). CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). Return(true, nil) mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil) authz.Handler(mock.Ctx) @@ -1172,7 +1503,7 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfTwoFactorDomain() { s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) @@ -1186,43 +1517,39 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfTwoFactorDomain() { mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - switch s.implementation { - case AuthzImplLegacy: - break - default: - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://two-factor.example.com", - RequestMethod: fasthttp.MethodGet, - } + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: true, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://two-factor.example.com", + RequestMethod: fasthttp.MethodGet, } + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + mock.UserProviderMock.EXPECT(). CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). Return(true, nil) mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil) authz.Handler(mock.Ctx) @@ -1244,7 +1571,7 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfDenyDomain() { s.T().Skip() } - authz := s.Builder().Build() + authz := s.BuildWithDelayer() mock := mocks.NewMockAutheliaCtx(s.T()) @@ -1258,43 +1585,39 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfDenyDomain() { mock.Ctx.Request.Header.Set(fasthttp.HeaderProxyAuthorization, "Basic am9objpwYXNzd29yZA==") - switch s.implementation { - case AuthzImplLegacy: - break - default: - mock.StorageMock. - EXPECT(). - LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - - mock.StorageMock. - EXPECT(). - LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) + mock.StorageMock. + EXPECT(). + LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) - attempt := model.AuthenticationAttempt{ - Time: mock.Ctx.Providers.Clock.Now(), - Successful: true, - Banned: false, - Username: "john", - Type: regulation.AuthType1FA, - RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), - RequestURI: "https://deny.example.com", - RequestMethod: fasthttp.MethodGet, - } + mock.StorageMock. + EXPECT(). + LoadBannedUser(gomock.Eq(mock.Ctx), gomock.Eq("john")).Return(nil, nil) - mock.StorageMock. - EXPECT(). - AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + attempt := model.AuthenticationAttempt{ + Time: mock.Ctx.Providers.Clock.Now(), + Successful: true, + Banned: false, + Username: "john", + Type: regulation.AuthType1FA, + RemoteIP: model.NewNullIP(mock.Ctx.RemoteIP()), + RequestURI: "https://deny.example.com", + RequestMethod: fasthttp.MethodGet, } + mock.StorageMock. + EXPECT(). + AppendAuthenticationLog(gomock.Eq(mock.Ctx), gomock.Eq(attempt)).Return(nil) + mock.UserProviderMock.EXPECT(). CheckUserPassword(gomock.Eq("john"), gomock.Eq("password")). Return(true, nil) mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil) authz.Handler(mock.Ctx) @@ -1311,9 +1634,15 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomainWithAuthorizationHead builder := NewAuthzBuilder().WithImplementationLegacy() + header := NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic") + header.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + headerProxy := NewHeaderProxyAuthorizationAuthnStrategy(time.Duration(0), "basic") + headerProxy.delay = header.delay + builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic"), - NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Duration(0), "basic"), + header, + headerProxy, NewCookieSessionAuthnStrategy(builder.config.RefreshInterval), ) @@ -1371,8 +1700,9 @@ func (s *AuthzSuite) TestShouldApplyPolicyOfOneFactorDomainWithAuthorizationHead mock.UserProviderMock.EXPECT(). GetDetails(gomock.Eq("john")). Return(&authentication.UserDetails{ - Emails: []string{"john@example.com"}, - Groups: []string{"dev", "admins"}, + Username: "john", + Emails: []string{"john@example.com"}, + Groups: []string{"dev", "admins"}, }, nil) authz.Handler(mock.Ctx) @@ -1391,9 +1721,15 @@ func (s *AuthzSuite) TestShouldHandleAuthzWithoutHeaderNoCookie() { builder := NewAuthzBuilder().WithImplementationLegacy() + header := NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic") + header.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + headerAuth := NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Duration(0), "basic") + headerAuth.delay = header.delay + builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic"), - NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Duration(0), "basic"), + header, + headerAuth, ) authz := builder.Build() @@ -1422,9 +1758,15 @@ func (s *AuthzSuite) TestShouldHandleAuthzWithEmptyAuthorizationHeader() { builder := NewAuthzBuilder().WithImplementationLegacy() + header := NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic") + header.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + headerProxy := NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Duration(0), "basic") + headerProxy.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic"), - NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Duration(0), "basic"), + header, + headerProxy, ) authz := builder.Build() @@ -1453,9 +1795,15 @@ func (s *AuthzSuite) TestShouldHandleAuthzWithAuthorizationHeaderInvalidPassword builder := NewAuthzBuilder().WithImplementationLegacy() + header := NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic") + header.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + + headerProxy := NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Duration(0), "basic") + headerProxy.delay = header.delay + builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic"), - NewHeaderProxyAuthorizationAuthRequestAuthnStrategy(time.Duration(0), "basic"), + header, + headerProxy, ) authz := builder.Build() @@ -1476,6 +1824,10 @@ func (s *AuthzSuite) TestShouldHandleAuthzWithAuthorizationHeaderInvalidPassword case AuthzImplLegacy: break default: + mock.UserProviderMock. + EXPECT(). + GetDetails(gomock.Eq("john")).Return(&authentication.UserDetails{Username: "john"}, nil) + mock.StorageMock. EXPECT(). LoadBannedIP(gomock.Eq(mock.Ctx), gomock.Eq(model.NewIP(mock.Ctx.RemoteIP()))).Return(nil, nil) @@ -1523,8 +1875,11 @@ func (s *AuthzSuite) TestShouldHandleAuthzWithIncorrectAuthHeader() { // TestSho builder := s.Builder() + header := NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic") + header.delay = middlewares.NewTimingAttackDelay(1, time.Millisecond).SetMinimumDelay(10).SetRecord(false) + builder = builder.WithStrategies( - NewHeaderAuthorizationAuthnStrategy(time.Duration(0), "basic"), + header, ) authz := builder.Build()
internal/handlers/handler_firstfactor_password.go+6 −6 modified@@ -13,14 +13,14 @@ import ( // FirstFactorPasswordPOST is the handler performing the first factor authn with a password. // //nolint:gocyclo // TODO: Consider refactoring time permitting. -func FirstFactorPasswordPOST(delayFunc middlewares.TimingAttackDelayFunc) middlewares.RequestHandler { +func FirstFactorPasswordPOST(delayer middlewares.Delayer) middlewares.RequestHandler { return func(ctx *middlewares.AutheliaCtx) { var successful bool requestTime := time.Now() - if delayFunc != nil { - defer delayFunc(ctx, requestTime, &successful) + if delayer != nil { + defer delayer.Delay(ctx, requestTime, &successful) } bodyJSON := bodyFirstFactorRequest{} @@ -163,14 +163,14 @@ func FirstFactorPasswordPOST(delayFunc middlewares.TimingAttackDelayFunc) middle // FirstFactorReauthenticatePOST is a specialized handler which checks the currently logged in users current password // and updates their last authenticated time. -func FirstFactorReauthenticatePOST(delayFunc middlewares.TimingAttackDelayFunc) middlewares.RequestHandler { +func FirstFactorReauthenticatePOST(delayer middlewares.Delayer) middlewares.RequestHandler { return func(ctx *middlewares.AutheliaCtx) { var successful bool requestTime := time.Now() - if delayFunc != nil { - defer delayFunc(ctx, requestTime, &successful) + if delayer != nil { + defer delayer.Delay(ctx, requestTime, &successful) } bodyJSON := bodyFirstFactorReauthenticateRequest{}
internal/handlers/handler_reset_password.go+1 −1 modified@@ -262,7 +262,7 @@ var ResetPasswordIdentityStart = middlewares.IdentityVerificationStart(middlewar RevokeEndpoint: "/revoke/reset-password", ActionClaim: ActionResetPassword, IdentityRetrieverFunc: identityRetrieverFromStorage, -}, middlewares.TimingAttackDelay(10, 250, 85, time.Millisecond*500, false)) +}, middlewares.NewTimingAttackDelay(10, time.Millisecond*500)) func resetPasswordIdentityVerificationFinish(ctx *middlewares.AutheliaCtx, username string) { var (
internal/handlers/handler_sign_password.go+3 −3 modified@@ -10,14 +10,14 @@ import ( // SecondFactorPasswordPOST is the handler performing the knowledge based authentication factor after a user utilizes a // alternative to usernames and passwords like passkeys. -func SecondFactorPasswordPOST(delayFunc middlewares.TimingAttackDelayFunc) middlewares.RequestHandler { +func SecondFactorPasswordPOST(delayer middlewares.Delayer) middlewares.RequestHandler { return func(ctx *middlewares.AutheliaCtx) { var successful bool requestTime := time.Now() - if delayFunc != nil { - defer delayFunc(ctx, requestTime, &successful) + if delayer != nil { + defer delayer.Delay(ctx, requestTime, &successful) } bodyJSON := bodySecondFactorPasswordRequest{}
internal/handlers/handler_sign_password_test.go+1 −3 modified@@ -145,9 +145,7 @@ func (s *HandlerSignPasswordSuite) TestShouldRedirectUserToDefaultURLDelayFunc() s.Require().NoError(err) s.mock.Ctx.Request.SetBody(bodyBytes) - delayFunc := func(ctx *middlewares.AutheliaCtx, requestTime time.Time, successful *bool) {} - - SecondFactorPasswordPOST(delayFunc)(s.mock.Ctx) + SecondFactorPasswordPOST(middlewares.NewTimingAttackDelay(10, time.Millisecond))(s.mock.Ctx) s.mock.Assert200OK(s.T(), redirectResponse{ Redirect: testRedirectionURLString,
internal/middlewares/authelia_context.go+7 −0 modified@@ -7,6 +7,7 @@ import ( "net" "net/url" "strings" + "time" "github.com/asaskevich/govalidator" "github.com/go-webauthn/webauthn/protocol" @@ -792,6 +793,12 @@ func (ctx *AutheliaCtx) GetWebAuthnProvider() (w *webauthn.WebAuthn, err error) return webauthn.New(config) } +func (ctx *AutheliaCtx) RecordAuthenticationDuration(success bool, elapsed time.Duration) { + if ctx.Providers.Metrics != nil { + ctx.Providers.Metrics.RecordAuthenticationDuration(success, elapsed) + } +} + // Value is a shaded method of context.Context which returns the AutheliaCtx struct if the key is the internal key // otherwise it returns the shaded value. func (ctx *AutheliaCtx) Value(key any) any {
internal/middlewares/identity_verification.go+3 −3 modified@@ -15,7 +15,7 @@ import ( ) // IdentityVerificationStart the handler for initiating the identity validation process. -func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc TimingAttackDelayFunc) RequestHandler { +func IdentityVerificationStart(args IdentityVerificationStartArgs, delayer Delayer) RequestHandler { if args.IdentityRetrieverFunc == nil { panic(fmt.Errorf("identity verification requires an identity retriever")) } @@ -33,8 +33,8 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim requestTime := time.Now() success := false - if delayFunc != nil { - defer delayFunc(ctx, requestTime, &success) + if delayer != nil { + defer delayer.Delay(ctx, requestTime, &success) } identity, err := args.IdentityRetrieverFunc(ctx)
internal/middlewares/identity_verification_test.go+1 −3 modified@@ -71,9 +71,7 @@ func TestShouldFailIfJWTCannotBeSaved(t *testing.T) { Return(fmt.Errorf("cannot save")) args := newArgs(defaultRetriever) - middlewares.IdentityVerificationStart(args, func(ctx *middlewares.AutheliaCtx, requestTime time.Time, successful *bool) { - time.Sleep(time.Millisecond * 10) - })(mock.Ctx) + middlewares.IdentityVerificationStart(args, middlewares.NewTimingAttackDelay(10, time.Millisecond*10))(mock.Ctx) assert.Equal(t, fasthttp.StatusOK, mock.Ctx.Response.StatusCode()) assert.Equal(t, "cannot save", mock.Hook.LastEntry().Message)
internal/middlewares/timing_attack_delay.go+132 −37 modified@@ -1,74 +1,169 @@ package middlewares import ( + "context" "math" "math/big" "sync" "time" + + "github.com/sirupsen/logrus" + + "github.com/authelia/authelia/v4/internal/random" ) -// TimingAttackDelayFunc describes a function for preventing timing attacks via a delay. -type TimingAttackDelayFunc func(ctx *AutheliaCtx, requestTime time.Time, successful *bool) +type TimingContext interface { + context.Context + + GetRandom() random.Provider + GetLogger() *logrus.Entry + RecordAuthenticationDuration(success bool, elapsed time.Duration) +} -// TimingAttackDelay creates a new standard timing delay func. -func TimingAttackDelay(history int, minDelayMs float64, maxRandomMs int64, initialDelay time.Duration, record bool) TimingAttackDelayFunc { - var ( - mutex = &sync.Mutex{} - cursor = 0 - ) +type Delayer interface { + Delay(ctx TimingContext, requestTime time.Time, successfulPtr *bool) + CachedDelay(ctx TimingContext, requestTime time.Time, cachedPtr, successfulPtr *bool) +} - execDurationMovingAverage := make([]time.Duration, history) +// NewTimingAttackDelay creates a new TimingAttackDelay with successDelay and jitter enabled and record disabled by +// default. Use the Set* methods to override these defaults. +func NewTimingAttackDelay(history int, initialDelay time.Duration) *TimingAttackDelay { + execDurationMovingAverage := make([]int64, history) for i := range execDurationMovingAverage { - execDurationMovingAverage[i] = initialDelay + execDurationMovingAverage[i] = initialDelay.Milliseconds() } - return func(ctx *AutheliaCtx, requestTime time.Time, successful *bool) { - successfulValue := false - if successful != nil { - successfulValue = *successful - } + return &TimingAttackDelay{ + history: history, + minDelayMs: 250, + maxJitterMs: 85, + successDelay: true, + jitter: true, + record: false, + mutex: &sync.Mutex{}, + execDurationMovingAverage: execDurationMovingAverage, + } +} + +// TimingAttackDelay is used to prevent timing attacks by introducing a delay relative to a moving average of past +// request durations. +type TimingAttackDelay struct { + history int + minDelayMs float64 + maxJitterMs int64 + + successDelay bool + jitter bool + record bool + + mutex *sync.Mutex + cursor int + execDurationMovingAverage []int64 +} - execDuration := time.Since(requestTime) +func (d *TimingAttackDelay) SetMinimumDelayDuration(duration time.Duration) *TimingAttackDelay { + ms := duration.Milliseconds() - if record && ctx.Providers.Metrics != nil { - ctx.Providers.Metrics.RecordAuthenticationDuration(successfulValue, execDuration) - } + return d.SetMinimumDelay(float64(ms)) +} + +func (d *TimingAttackDelay) SetMinimumDelay(minDelayMs float64) *TimingAttackDelay { + d.minDelayMs = minDelayMs + + return d +} + +// SetSuccessDelay configures whether a delay is applied. Defaults to true. +func (d *TimingAttackDelay) SetSuccessDelay(successDelay bool) *TimingAttackDelay { + d.successDelay = successDelay + + return d +} + +// SetJitter configures whether random jitter is added to the delay. Defaults to true. +func (d *TimingAttackDelay) SetJitter(jitter bool, maxJitterMs int64) *TimingAttackDelay { + d.jitter = jitter + d.maxJitterMs = maxJitterMs + + return d +} - execDurationAvgMs := movingAverageIteration(execDuration, history, successfulValue, &cursor, &execDurationMovingAverage, mutex) - actualDelayMs := calculateActualDelay(ctx, execDuration, execDurationAvgMs, minDelayMs, maxRandomMs, successfulValue) - time.Sleep(time.Duration(actualDelayMs) * time.Millisecond) +// SetRecord configures whether authentication durations are recorded via the TimingContext. Defaults to false. +func (d *TimingAttackDelay) SetRecord(record bool) *TimingAttackDelay { + d.record = record + + return d +} + +// Delay implements TimingAttackDelayFunc. +func (d *TimingAttackDelay) Delay(ctx TimingContext, requestTime time.Time, successfulPtr *bool) { + d.CachedDelay(ctx, requestTime, nil, successfulPtr) +} + +// CachedDelay implements TimingAttackDelayFunc. +func (d *TimingAttackDelay) CachedDelay(ctx TimingContext, requestTime time.Time, cachedPtr, successfulPtr *bool) { + var cached, successful bool + + if successfulPtr != nil { + successful = *successfulPtr + } + + if cachedPtr != nil { + cached = *cachedPtr + } + + execDuration, execDurationAvgMs := d.movingAverageIteration(requestTime, cached, successful) + + if d.record { + ctx.RecordAuthenticationDuration(successful, execDuration) + } + + if successful && !d.successDelay { + return } + + actualDelayMs := calculateActualDelay(ctx, execDuration, execDurationAvgMs, d.minDelayMs, d.maxJitterMs, d.jitter, successful) + + time.Sleep(time.Duration(actualDelayMs) * time.Millisecond) } -func movingAverageIteration(value time.Duration, history int, successful bool, cursor *int, movingAvg *[]time.Duration, mutex sync.Locker) float64 { - mutex.Lock() +func (d *TimingAttackDelay) movingAverageIteration(requestTime time.Time, cached, successful bool) (execDuration time.Duration, execDurationAvgMs float64) { + d.mutex.Lock() var sum int64 - for _, v := range *movingAvg { - sum += v.Milliseconds() + for _, v := range d.execDurationMovingAverage { + sum += v } - if successful { - (*movingAvg)[*cursor] = value - *cursor = (*cursor + 1) % history + execDuration = time.Since(requestTime) + + if successful && !cached { + d.execDurationMovingAverage[d.cursor] = execDuration.Milliseconds() + d.cursor = (d.cursor + 1) % d.history } - mutex.Unlock() + d.mutex.Unlock() - return float64(sum / int64(history)) + return execDuration, float64(sum) / float64(d.history) } -func calculateActualDelay(ctx *AutheliaCtx, execDuration time.Duration, execDurationAvgMs, minDelayMs float64, maxRandomMs int64, successful bool) (actualDelayMs float64) { - randomDelayMs, err := ctx.Providers.Random.IntErr(big.NewInt(maxRandomMs)) - if err != nil { - return float64(maxRandomMs) +func calculateActualDelay(ctx TimingContext, execDuration time.Duration, execDurationAvgMs, minDelayMs float64, maxRandomMs int64, jitter, successful bool) (actualDelayMs float64) { + var jitterMs *big.Int + + if jitter { + jitterMs, _ = ctx.GetRandom().IntErr(big.NewInt(maxRandomMs)) } - totalDelayMs := math.Max(execDurationAvgMs, minDelayMs) + float64(randomDelayMs.Int64()) + if jitterMs == nil { + jitterMs = big.NewInt(0) + } + + totalDelayMs := math.Max(execDurationAvgMs, minDelayMs) + float64(jitterMs.Int64()) actualDelayMs = math.Max(totalDelayMs-float64(execDuration.Milliseconds()), 1.0) - ctx.Logger.Tracef("Timing Attack Delay successful: %t, exec duration: %d, avg execution duration: %d, random delay ms: %d, total delay ms: %d, actual delay ms: %d", successful, execDuration.Milliseconds(), int64(execDurationAvgMs), randomDelayMs.Int64(), int64(totalDelayMs), int64(actualDelayMs)) + + ctx.GetLogger().Tracef("Timing Attack Delay successful: %t, exec duration: %d, avg execution duration: %d, random delay ms: %d, total delay ms: %d, actual delay ms: %d", successful, execDuration.Milliseconds(), int64(execDurationAvgMs), jitterMs.Int64(), int64(totalDelayMs), int64(actualDelayMs)) return actualDelayMs }
internal/middlewares/timing_attack_delay_test.go+11 −9 modified@@ -16,11 +16,13 @@ import ( func TestTimingAttackDelayAverages(t *testing.T) { execDuration := time.Millisecond * 500 oneSecond := time.Millisecond * 1000 - durations := []time.Duration{oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond, oneSecond} - cursor := 0 - mutex := &sync.Mutex{} - avgExecDuration := movingAverageIteration(execDuration, 10, false, &cursor, &durations, mutex) - assert.Equal(t, avgExecDuration, float64(1000)) + d := &TimingAttackDelay{ + history: 10, + mutex: &sync.Mutex{}, + execDurationMovingAverage: []int64{oneSecond.Milliseconds(), oneSecond.Milliseconds(), oneSecond.Milliseconds(), oneSecond.Milliseconds(), oneSecond.Milliseconds(), oneSecond.Milliseconds(), oneSecond.Milliseconds(), oneSecond.Milliseconds(), oneSecond.Milliseconds(), oneSecond.Milliseconds()}, + } + _, avgExecDuration := d.movingAverageIteration(time.Now().Add(-execDuration), false, false) + assert.InDelta(t, float64(1000), avgExecDuration, 1) execDurations := []time.Duration{ time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500, time.Millisecond * 500, @@ -32,8 +34,8 @@ func TestTimingAttackDelayAverages(t *testing.T) { // Execute at 500ms for 12 requests. for _, execDuration = range execDurations { - avgExecDuration = movingAverageIteration(execDuration, 10, true, &cursor, &durations, mutex) - assert.Equal(t, avgExecDuration, current) + _, avgExecDuration = d.movingAverageIteration(time.Now().Add(-execDuration), false, true) + assert.InDelta(t, current, avgExecDuration, 1) // Should not dip below 500, and should decrease in value by 50 each iteration. if current > 500 { @@ -56,7 +58,7 @@ func TestTimingAttackDelayCalculations(t *testing.T) { } for i := 0; i < 100; i++ { - delay := calculateActualDelay(ctx, execDuration, avgExecDurationMs, 250, 85, false) + delay := calculateActualDelay(ctx, execDuration, avgExecDurationMs, 250, 85, true, false) assert.True(t, delay >= expectedMinimumDelayMs) assert.True(t, delay <= expectedMinimumDelayMs+float64(85)) } @@ -66,7 +68,7 @@ func TestTimingAttackDelayCalculations(t *testing.T) { expectedMinimumDelayMs = 250 - float64(execDuration.Milliseconds()) for i := 0; i < 100; i++ { - delay := calculateActualDelay(ctx, execDuration, avgExecDurationMs, 250, 85, false) + delay := calculateActualDelay(ctx, execDuration, avgExecDurationMs, 250, 85, true, false) assert.True(t, delay >= expectedMinimumDelayMs) assert.True(t, delay <= expectedMinimumDelayMs+float64(250)) }
internal/model/oidc.go+4 −0 modified@@ -410,6 +410,10 @@ func (s *OAuth2Session) SetSubject(subject string) { // ToRequest converts an OAuth2Session into a oauthelia2.Request given a oauthelia2.Session and oauthelia2.Storage. func (s *OAuth2Session) ToRequest(ctx context.Context, session oauthelia2.Session, store oauthelia2.Storage) (request *oauthelia2.Request, err error) { + if s == nil { + return nil, fmt.Errorf("error occurred while mapping OAuth 2.0 Session back to a Request: the OAuth 2.0 Session is nil") + } + sessionData := s.Session if session != nil {
internal/model/oidc_test.go+7 −0 modified@@ -389,6 +389,13 @@ func TestOAuth2Session_ToRequest(t *testing.T) { expected *oauthelia2.Request err string }{ + { + "ShouldErrorOnNilReceiver", + nil, + nil, + nil, + "error occurred while mapping OAuth 2.0 Session back to a Request: the OAuth 2.0 Session is nil", + }, { "ShouldErrorInvalidJSONData", nil,
internal/server/handlers.go+4 −4 modified@@ -253,10 +253,10 @@ func handlerMain(ctx context.Context, config *schema.Configuration, providers mi r.POST("/api/checks/safe-redirection", middlewareAPI(handlers.CheckSafeRedirectionPOST)) - funcDelayPassword := middlewares.TimingAttackDelay(10, 250, 85, time.Second, true) + delayerPassword := middlewares.NewTimingAttackDelay(10, time.Second).SetRecord(true) - r.POST("/api/firstfactor", middlewareAPI(handlers.FirstFactorPasswordPOST(funcDelayPassword))) - r.POST("/api/firstfactor/reauthenticate", middleware1FA(handlers.FirstFactorReauthenticatePOST(funcDelayPassword))) + r.POST("/api/firstfactor", middlewareAPI(handlers.FirstFactorPasswordPOST(delayerPassword))) + r.POST("/api/firstfactor/reauthenticate", middleware1FA(handlers.FirstFactorReauthenticatePOST(delayerPassword))) r.POST("/api/logout", middlewareAPI(handlers.LogoutPOST)) // Only register endpoints if forgot password is not disabled. @@ -322,7 +322,7 @@ func handlerMain(ctx context.Context, config *schema.Configuration, providers mi if config.WebAuthn.EnablePasskeyLogin { r.GET("/api/firstfactor/passkey", middlewareAPI(handlers.FirstFactorPasskeyGET)) r.POST("/api/firstfactor/passkey", middlewareAPI(handlers.FirstFactorPasskeyPOST)) - r.POST("/api/secondfactor/password", middleware1FA(handlers.SecondFactorPasswordPOST(funcDelayPassword))) + r.POST("/api/secondfactor/password", middleware1FA(handlers.SecondFactorPasswordPOST(delayerPassword))) } // Management of the WebAuthn credentials.
Vulnerability mechanics
Root cause
"Missing canonicalization of the basic auth username before regulation ban checks allows case variations to bypass account lockout."
Attack vector
An attacker can send HTTP requests with the `Authorization: Basic` header using different case variations of a victim's username (e.g., `john`, `John`, `JOHN`). Because LDAP treats usernames case-insensitively, each variation successfully authenticates as the same user. However, the regulation system's SQL queries treat the username as case-sensitive, so each case variation gets its own separate ban bucket. This allows an attacker to effectively bypass the account lockout/ban mechanism by cycling through case permutations, degrading the effectiveness of brute-force protections.
Affected code
The vulnerability resides in the authz verification endpoint handlers (`internal/handlers/handler_authz_test.go` and related production code). The basic auth username extracted from the `Authorization` header was passed directly to the regulation system for ban checking and attempt recording without canonicalization. The patch introduces a `GetDetails` call to obtain the canonical username from the LDAP backend before performing regulation lookups.
What the fix does
The patch adds a call to `GetDetails` on the user provider to retrieve the canonical username from the LDAP backend before performing ban checks and recording authentication attempts. By obtaining the username as stored in LDAP (rather than using the raw value from the `Authorization` header), the regulation system now consistently uses the same case for all lookups, ensuring that all case variations of a username map to the same ban bucket. The patch also introduces a `TimingAttackDelay` to mitigate timing side-channels in the basic auth handling.
Preconditions
- configLDAP authentication backend must be in use
- configBasic auth mechanism must be enabled in the Authelia configuration
- configThe underlying database must use case-sensitive collation (SQLite and PostgreSQL are affected; MySQL with case-insensitive collation is not)
- configThe administrator must not be using IP-based regulation mode or a third-party tool like CrowdSec/fail2ban
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.