Skip to content

Improvements for automated login events#3857

Merged
cataphract merged 8 commits into
masterfrom
automated-login-event-improvs
May 6, 2026
Merged

Improvements for automated login events#3857
cataphract merged 8 commits into
masterfrom
automated-login-event-improvs

Conversation

@cataphract
Copy link
Copy Markdown
Contributor

Description

  • Emit missing_user_login / missing_user_id telemetry metrics tagged with the framework name when login/id are absent
  • WordPress: stop silently swallowing failures with empty username (pass null through so missing_user_login telemetry fires correctly)
  • Add WordPress integration test app and WordPressTests test class
  • Improve login failure events on symfony/laravel so that usr.login (and on, laravel, possibly usr.id) are passed

Reviewer checklist

  • Test coverage seems ok.
  • Appropriate labels assigned.

@cataphract cataphract requested review from a team as code owners May 5, 2026 13:57
@datadog-official

This comment has been minimized.

Comment thread appsec/src/extension/tags.c Outdated
@pr-commenter
Copy link
Copy Markdown

pr-commenter Bot commented May 5, 2026

Benchmarks [ appsec ]

Benchmark execution time: 2026-05-06 00:23:00

Comparing candidate commit 734b8fc in PR branch automated-login-event-improvs with baseline commit 82abdf3 in branch master.

Found 0 performance improvements and 0 performance regressions! Performance is the same for 12 metrics, 0 unstable metrics.

@pr-commenter
Copy link
Copy Markdown

pr-commenter Bot commented May 5, 2026

Benchmarks [ tracer ]

Benchmark execution time: 2026-05-06 01:03:16

Comparing candidate commit 734b8fc in PR branch automated-login-event-improvs with baseline commit 82abdf3 in branch master.

Found 0 performance improvements and 0 performance regressions! Performance is the same for 194 metrics, 0 unstable metrics.

@cataphract cataphract force-pushed the automated-login-event-improvs branch from da9643c to 0c4b0e8 Compare May 5, 2026 18:07
@cataphract cataphract requested a review from a team as a code owner May 5, 2026 18:07
@cataphract cataphract force-pushed the automated-login-event-improvs branch from 8324701 to 734b8fc Compare May 5, 2026 23:44
@cataphract
Copy link
Copy Markdown
Contributor Author

system test failures are expected because the system-tests rely on calling on the implementation-detail _automated php functions. This will be handed on a separate system-tests pr

cataphract and others added 8 commits May 6, 2026 15:05
…ogin failure

- Accept optional `framework` parameter in all `track_user_*` PHP functions
- Emit `missing_user_login` / `missing_user_id` telemetry metrics tagged with
  the framework name when login/id are absent
- Pass `'laravel'`, `'symfony'`, `'wordpress'` from each integration
- WordPress: stop silently swallowing failures with empty username (pass null
  through so missing_user_login telemetry fires correctly)
- Add WordPress integration test app and WordPressTests test class
- Update Laravel8x and Symfony62 integration tests to assert framework tag on
  missing_user_login / missing_user_id metrics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Symfony's auto-instrumented login_failure hooks were calling
track_user_login_failure_event_automated(null, null, false, ...);
Laravel's pre-existing post-hook on SessionGuard::attempt did extract
the login from credentials but always reported the user id as null. With
the framework tag now emitted on missing_user_login / missing_user_id
telemetry, every failure on these frameworks was being counted as
missing usr.login or usr.id even when the framework had it in hand. This
commit teaches both integrations to extract the attempted login and,
where available, the resolved user id, populating
appsec.events.users.login.failure.usr.{login,id}.

Symfony
-------
Symfony's Security component went through three generations and we hook the
class on the login-failure path of each. The (Request, AuthenticationException)
signature is shared across all three, so a single extractor handles all of
them.

  Guard component (4.x-5.x; deprecated 5.3, removed in 6.0)
    AbstractGuardAuthenticator implements Guard\AuthenticatorInterface
        ↑ extends
    AbstractFormLoginAuthenticator                       <-- hooked
        ::onAuthenticationFailure(Request, AuthenticationException)

  Legacy HTTP firewall listener (5.x; removed in 6.0)
    AbstractListener implements FirewallListenerInterface
        ↑ extends
    AbstractAuthenticationListener                       <-- hooked
        ::onFailure(Request, AuthenticationException)        [private]

  New authenticator system (5.1 experimental, 5.3 stable, only option in 6.0+)
    AuthenticatorInterface
        ↑ implemented by
    AbstractAuthenticator
        ↑ extends
    AbstractLoginFormAuthenticator implements
        AuthenticationEntryPointInterface,
        InteractiveAuthenticatorInterface
        ↑ extends
    FormLoginAuthenticator                               <-- hooked
        ::onAuthenticationFailure(Request, AuthenticationException)

The exception side is shared:

    \RuntimeException
        ↑
    AuthenticationException                  (getToken(): ?TokenInterface,
                                              setToken(TokenInterface))
        ↑ extends
    UserNotFoundException        (5.3+)      (getUserIdentifier(): ?string)
    UsernameNotFoundException    (≤5.2; in 5.3-5.4 a class_alias forwarder
                                  to UserNotFoundException via class_exists();
                                  removed in 6.0; getUsername()/setUsername())
    BadCredentialsException                  (no identifier accessor)

Token attachment differs by manager:
- AuthenticationProviderManager (legacy provider chain) calls
  $exception->setToken($token) before throwing, so the failed identifier
  survives via getToken()->getUserIdentifier().
- AuthenticatorManager (new system, only option from 6.0) does not. Its
  handleAuthenticationFailure is invoked after the Passport's UserBadge
  resolution has thrown; it does not setToken on the exception. By default
  (hideUserNotFoundExceptions = true) it wraps the original UserNotFoundException
  inside a fresh BadCredentialsException as $previous to prevent user
  enumeration — so the identifier is still reachable, just one level deeper
  in the exception chain.

extractLoginFromAuthFailure() therefore tries three sources in order:

  A. $exception->getToken()->getUserIdentifier() — picks up the legacy
     ProviderManager case and any custom code that calls setToken().
  B. Walk the exception chain via getPrevious() for a node exposing
     getUserIdentifier() (UserNotFoundException, 5.3+) or the deprecated
     getUsername() (UsernameNotFoundException). Reaches the wrapped
     UserNotFoundException under the new manager.
  C. $request->getSession()->get('_security.last_username') — the form-login
     authenticator stashes the submitted username into the session under
     this key as the very first step of authenticate() (in getCredentials(),
     before the UserBadge is constructed). The constant is
     SecurityRequestAttributes::LAST_USERNAME.

usr.id is left null on Symfony failures: AuthenticationException carries a
TokenInterface at most (and only sometimes), not a UserInterface.

Laravel
-------
Laravel's auth contracts and the concrete classes we hook:

    Illuminate\Contracts\Auth\Guard
        ↑ extends
    Illuminate\Contracts\Auth\StatefulGuard
        ↑ implemented by
    Illuminate\Auth\SessionGuard implements
        StatefulGuard, SupportsBasicAuth                 (Laravel >= 5.2)

    Illuminate\Auth\Guard                                (Laravel <= 5.1;
                                                          a separate concrete
                                                          class, distinct from
                                                          the 5.0+ Guard contract)

    Illuminate\Contracts\Auth\Authenticatable            (getAuthIdentifier())
    Illuminate\Contracts\Auth\UserProvider               (retrieveByCredentials,
                                                          validateCredentials)

    Illuminate\Auth\Events\Failed                        (introduced in 5.2.35)
    Illuminate\Auth\Events\Login                         (plain event objects;
                                                          no shared base)

SessionGuard::attempt() (5.2+) is the canonical entry point and runs:

  1. fireAttemptEvent($credentials, $remember)
  2. $user = $provider->retrieveByCredentials($credentials)   // by email/username
  3. hasValidCredentials($user, $credentials)                 // verifies password
  4. on failure: fireFailedEvent($user, $credentials)         // dispatches Failed
     on success: login($user, $remember); return true

The Failed event therefore carries:
  - the resolved $user when retrieveByCredentials matched a row
    (i.e., wrong-password case → usr.id is known),
  - $user === null when no row matched (user-not-found case),
  - the credentials array in either case (so usr.login is always derivable).

Hooking Failed::__construct gives us everything in one place. The signature
changed across versions: ($user, $credentials) on 5.2-5.6,
($guard, $user, $credentials) on 5.7+; we branch on count($args).

The pre-existing SessionGuard::attempt post-hook is kept as a fallback for
the 5.2.0-5.2.34 window (SessionGuard already exists but does not dispatch
Failed yet — fireFailedEvent was added in 5.2.35). It now skips when
class_exists('Illuminate\Auth\Events\Failed', false) is true — i.e.,
whenever the event class has been autoloaded into the request, which only
happens once Laravel itself has dispatched it. Without this guard the
post-hook would fire after Failed::__construct and overwrite the captured
usr.id with null. The Laravel <= 5.1 hook on Illuminate\Auth\Guard::attempt
is unchanged (Auth\Guard was renamed to Auth\SessionGuard in 5.2 and is a
separate concrete class).

Tests
-----
- Symfony62Tests: replace the missing-login telemetry assertion (no longer
  triggered, since the integration now extracts '_username=aa' via the
  session path) with a usr.login == 'aa' assertion.
- Laravel8xTests: existing login-failure test now also asserts usr.login
  == the attempted email; new test 'wrong password for existing user'
  asserts usr.exists/usr.id/usr.login when the user is found but the
  password is wrong; the missing-login-telemetry test is unchanged
  (empty email still produces login=='' which still triggers
  missing_user_login + missing_user_id).
- laravel8x LoginController: accept ?password= so the fixture can drive
  the wrong-password path against an existing seeded user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ilure

?string return type hint breaks class loading on PHP 7.0, causing all
Symfony instrumentation spans to be lost.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These functions are called by framework integrations, not end users.
Placing them under an `internal` sub-namespace makes that explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cataphract cataphract force-pushed the automated-login-event-improvs branch from 734b8fc to 6d4437e Compare May 6, 2026 14:05
@cataphract cataphract enabled auto-merge May 6, 2026 14:06
@cataphract cataphract merged commit b446a7e into master May 6, 2026
21 of 22 checks passed
@cataphract cataphract deleted the automated-login-event-improvs branch May 6, 2026 14:06
@github-actions github-actions Bot modified the milestone: 1.20.0 May 6, 2026
@github-actions github-actions Bot added profiling Relates to the Continuous Profiler tracing area:asm labels May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:asm profiling Relates to the Continuous Profiler tracing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants