The Drupal Session Inspector module allows a user to view their current user sessions on a Drupal site. By allowing users to view this information you create a self-service security system where users can see if any unusual activity exists on their account themselves. The module also allows users to delete any sessions they don't like the look of, which protects their account.
This session deletion feature is especially useful in situations where the user has neglected to log out of a computer. They can just log into the site in another location and revoke that session, securing their account from accidental access.
As part of my continuing work on the Session Inspector module, I wanted to add an events system that could react to this deletion of sessions.
We use a lot of single sign-on services at Code Enigma, so it made sense to add events to the module so that we could then react to these events. This means that when a user deletes a session we can push that event into the single sign-on service and destroy that session globally. Without this action in place, the deletion of session data from Drupal would have no effect since the session would just be regenerated if the user visited the Drupal site again through that browser.
What happens to the event after it is triggered is beyond the scope of the module since adding in the event reactions would create lots of dependencies; either with other modules or packages that integrate with single sign-on systems. These dependencies would make the module far more complex than it needs to be.
In this article, I will go through setting up the events system within the module and then go on to create a testing harness to ensure that the events are triggered correctly.
First, let's look at adding custom events to the Session Inspector module.
Adding Custom Events to the Drupal Session Inspector module
There are a few components needed to create custom events in Drupal, but they are actually pretty straightforward and don't actually need a lot of code. I have talked about the events system in Drupal before if you are interested in the finer details.
The components we need to add events are:
- A class to hold the event name as a constant.
- A class that extends the Drupal core Event class and adds any custom information required for the event to function.
- The addition of code to dispatch the event in the appropriate place.
When we trigger an event in Drupal we need to let the system know what sort of event was triggered. This is done using a constant that we keep in a class called SessionInspectorEvents, which only exists to keep things tidy.
This is the SessionInspectorEvents class.
<?php namespace Drupal\session_inspector\Event; /** * Contains all events thrown in the Session Inspector module. */ final class SessionInspectorEvents { /** * Event ID for when a session record is destroyed. * * @Event * * @var string */ const SESSION_DESTROYED = 'session_inspector.session_destroyed'; }
The PHP final keyword here is used to prevent this class from being extended and ensures that the name of our event will always be the same.
We only need to define the single event constant SESSION_DESTROYED, which we will trigger when a session is destroyed. The class doesn't need to contain any other code. If we want to add more events in the future we would need to add more constants to this class and then trigger them.
Not only is the event constant passed to the event when triggered, but information about the session at the centre of the event is also passed. This is done by extending the core Drupal\Component\EventDispatcher\Event class and adding whatever data we need to it. What we create here is essentially a data object, whose only purpose is to transmit data about the event from the trigger point to the event handler.
There is a fair amount of boilerplate code here to get and set different properties, but the class we need essentially looks like this.
<?php namespace Drupal\session_inspector\Event; use Drupal\Component\EventDispatcher\Event; /** * A data object to pass information about the session to the event. * * @package Drupal\session_inspector\Event */ class SessionEvent extends Event { /** * The user ID. * * @var int */ protected $uid; /** * The (hashed) session ID. * * @var string */ protected $sid; /** * The session hostname. * * @var string */ protected $hostname; /** * The session timestamp. * * @var int */ protected $timestamp; /** * Constructs a SessionEvent object. * * @param int $uid * The user ID. * @param string $sid * The (hashed) session ID. * @param string $hostname * The session hostname. * @param int $timestamp * The session timestamp. */ public function __construct($uid, $sid, $hostname, $timestamp) { $this->uid = $uid; $this->sid = $sid; $this->hostname = $hostname; $this->timestamp = $timestamp; } // snipped out the getter and setter methods for brevity. }
With those two classes we now have everything we need to start triggering the event.
The event can be triggered by modifying the submit handler of the session delete confirmation form (called UserSessionDeleteForm). In the submit handler of this form we need to create a new SessionEvent object that contains the data about the session before triggering the event, passing the object and the name of the event being triggered.
We do this using the core event_dispatcher service, which is also injected into the form using the container injection interface. Calling the dispatch() method will pass information from this point to any event subscriber that is listening to this event being triggered. Everything else about the form and the submission handler stays the same since the form already does the job it needs to do.
I won't add the entire form class here as it contains a fair amount of boiler plate code that isn't relevant. What is relevant is the submitForm() method, which is where we do the important event triggering. You can see that we set up the SessionEvent object, delete the user's session, and then pass the created object to the event using the dispatch() method. The form then redirects the user back the sessions page in the usual way.
public function submitForm(array &$form, FormStateInterface $form_state) { // Get the session data we are about to delete. $sessionData = $this->sessionInspector->getSession($this->sessionId); $sessionEvent = new SessionEvent($sessionData['uid'], $sessionData['sid'], $sessionData['hostname'], $sessionData['timestamp']); // Destroy the session. $this->sessionInspector->destroySession($this->sessionId); // Trigger the session event. $this->eventDispatcher->dispatch($sessionEvent, SessionInspectorEvents::SESSION_DESTROYED); // Redirect the user back to the session list. $form_state->setRedirectUrl($this->getCancelUrl()); }
If you want to see the entire form then you can find all of the source code in the Session Inspector module.
That's it for adding events to the module, but let's add a test to the module to ensure that everything is set up correctly.
Testing Events
With something as critical as destroying a session we really need to ensure that when the session is destroyed, the event is triggered correctly.
The best way to do this is by creating a module in the tests directory of the Session Inspector module. The sole purpose of this module is to register an event subscriber and perform an action on the event. This module has the following info.yml file.
name: 'Session Inspector Events Test' description: 'Functionality to assist session inspector event testing.' type: module hidden: true core_version_requirement: ^8.8 || ^9 dependencies: - session_inspector package: Testing
Note that we have set the "hidden" parameter to "true" in order to hide this testing module from the normal operation of the site. This means that site administrators can't turn on the module, which is a good thing as the testing plugins won't produce any meaningful output.
This test module contains a single service, which we define in the module's services.yml file. Here, we register the event subscriber class with Drupal using the event_subscriber tag, but we also pass in the Drupal core state API as a dependency.
services: session_inspector_events_test.session_inspector_event_subscriber: class: \Drupal\session_inspector_events_test\EventSubscriber\SessionInspectorEventsTest arguments: ['@state'] tags: - { name: event_subscriber }
The Drupal state API is a short term storage bucket that we can use to store values within the event listener of the test module that we can then pick back up and read in the test. This gives us a handy way of ensuring that the event both ran and contained the correct information.
The SessionInspectorEventsTest class implements the EventSubscriberInterface interface, which means that we have to add a method called getSubscribedEvents(). This method must return an associative array of the events we want this event subscriber to react to, which in this case is just the session destroyed event. This mechanism is used by Drupal to discover all of the available event subscribers on the site.
Drupal will call the method onSessionDestroyed when it detects that a session destroyed event is triggered.
public static function getSubscribedEvents() { $events[SessionInspectorEvents::SESSION_DESTROYED] = ['onSessionDestroyed']; return $events; }
The onSessionDestroyed() method accepts an event object as a single parameter. This is actually the SessionEvent object that we created in the UserSessionDeleteForm submit handler. All we need to do in this event listener is to use the state API to register the user and session ID of the session.
/** * Event callback when a session is destroyed. * * @param \Drupal\session_inspector\Event\SessionEvent $event * The event data. */ public function onSessionDestroyed(SessionEvent $event) { // Set a state so that we can detect that the event triggered. $this->state->set('session_event.uid', $event->getUid()); $this->state->set('session_event.sid', $event->getSid()); }
The Session Inspector module already contains a couple of tests that look at the core functionality of the module. This includes running through a session deletion step, so the test harness that we need is already present to add the assertion that the event was triggered.
What we need to do in order to test our event system is to add the session_inspector_events_test module to the list of installed modules used during the test. Since the module contains the necessary service to listen to the event we don't need any other configuration to be added.
protected static $modules = [ 'session_inspector', 'session_inspector_events_test', ];
Within the test that checks to see if a user can delete a session we add a few lines of code to ensure that the state API contains the values set during the event listener. More importantly, we also check that the values match with the expected values from the user session. Here is the modified section of the test.
// Delete on the delete. $this->click('[data-test="session-operation-0"] a'); $url = $this->getUrl(); preg_match('/user\/(?<uid>\d*)\/sessions\/(?<sid>.*)\/delete/', $url, $urlMatches); $uid = $urlMatches['uid']; $sid = $urlMatches['sid']; // Click confirm. $this->click('[id="edit-submit"]'); // Ensure that the session event was triggered and that it // contains the correct information. $this->assertEquals($uid, $this->container->get('state')->get('session_event.uid')); $this->assertEquals($sid, $this->container->get('state')->get('session_event.sid'));
The tests for the module now prove that when a user deletes a session that the event is triggered and that the information for that session is transmitted to the event correctly.
If you are interested in finding out more about the Drupal Session Inspector module then you can see the module page at https://www.drupal.org/project/session_inspector.