Drupal 9: Creating A Session Inspector Module

Drupal 9: Creating A Session Inspector Module

20th February 2022 - 35 minutes read time

I recently had this idea for a new Drupal module that allows users to manage their sessions. The idea is that a user could look at their current sessions to see if any suspicious activity had happened on their Drupal account. They could then delete any sessions they don't like the look of in order to kill off that session.

The functionality I describe here is actually quite important as it allows users the ability to spot suspicious activity on their accounts. I have even used it in the past when one of my accounts was hacked. The fact that the session was opened from Brazil helped me inform the site that something was wrong.

I did some searching on Drupal for a module that did the same but didn't find anything so I thought I would create a module and go through the process of creation in an article. This includes the research into how this can be achieved in Drupal, looking at designing the interface, and then adding code to create the needed effect.

By the way, if you do have an idea for a module it's always a good idea to do this search first as you might find someone has already built it. You can also avoid any namespace clashes with other modules that might cause you headaches in the future.

Let's start with seeing if the module is possible in Drupal.

Can we do this in Drupal?

At it happens we have the basic building blocks of this module built into the session management system within Drupal. There is a session_manager service in Drupal that wraps the core PHP session system and deals with controlling the sessions for a user. The core element of this service is the sessions table, which is used by Drupal to store the sessions for each user on the site. There aren't any session entities, so this table is the direct input/output stream of the session management system.

This is the structure of the sessions table.

  • uid - The user ID of the user that the session belongs to.
  • sid - The unique ID of the session, this is a hash of the PHP session ID and so can't be reverse engineered into a fully qualified session.
  • hostname - The IP address that the user accesses the site from.
  • timestamp - The timestamp that the session was last used. Every time the user accesses the site using this session the timestamp is updated.
  • session - Some metadata about the session. If the user signed in using a one time login link the token used will be stored here.

When a user logs into the site a record is added to this table using their user ID, hostname and the time that they logged in. Since we have a session ID linked to a user we have a neat way of presenting a list of sessions. The timestamp allows us to show how long ago the session was used and the hostname shows where in the world the session originated.

Due to the way in which sessions work there is no way to tell if a session is currently active or not, just when the session data was last written to the table. Drupal will eventually garbage collect these old sessions, but if their associated session cookies don't exist in the browser then they can't be used.

One thing we are able to tell is the sid of the current session. The session cookie contains a long alpha-numeric string, and this is hashed using a URL-safe sha-256 hashing algorithm to a different value when saving it to the database table. This means that when printing out the sessions we are able to detect the user's current session by comparing the value in the table with a hash of the currently used session ID.

After a bit of experimentation I confirmed that in order to log a user session out I just needed to delete an entry from this table. Doing this breaks the link between the browser and the session and so the user is logged out. This means we have direct control over a users sessions.

Design

What we need is a way for a user to be able to see what sessions they have with the Drupal site in a table.

This list should be displayed in a way that shows some information about the session and allows the user some control over them. It should be possible for a user to delete a session to close down anything they don't recognise.

Looking around on the internet I found examples of this sort of thing in Twitter, Facebook and AirBnB. This probably exists on quite a few platforms as it is becoming a more mainstream way of allowing users the ability to control their own sessions. This is especially important with people using multiple devices and browsers to access sites.

Here are some screenshots of these session inspector dialogs from various sites. I have redacted some of the information.

Twitter:

A screenshot of Twitter, showing a user's most recent sessions.

AirBnB:

A screenshot of AirBnB, showing a user's most recent sessions.

Facebook:

A screenshot of Facebook, showing a user's most recent sessions.

It is interesting to note that whilst my country is correct in the above panels, the actual location of my session jumps around quite a bit. I'd imagine this is because different sites use different geolocation data to convert IP addresses into a city, country location. Also, because my IP address is routed through an ISP and a VPN the location has some variation too.

These sites display a lot more information than we have at our disposal in Drupal. We don't currently have access to the operating system or browser in use as that information isn't stored by default in the session. Nor can we geolocate the IP address in Drupal without pulling in third party services.

Most sites that show off this information will have the ability to show an IP address to the user, although it's not directly visible all of the time. That is interesting as some users will want to see it and other users won't even know what the numbers are. Hiding it behind popups means that users who want to can get access to that info.

Within the Drupal module, the minimum viable product (MVP) should show a user their basic session information and allow them the ability to delete any session they need to. An indication of the currently active sessions should also be possible.

Since we are displaying sensitive information we must take user permissions very seriously with this module. No user should be able to see or delete the session information without being permission checked.

When I originally set out to build the module I had called it session_manager, which actually clashes with the internal Drupal service. I therefore renamed it to Session Inspector (or session_inspector), which was available both as a service and a module namespace on drupal.org. Naming things, as always, is hard!

Components needed

Let's look at the different components that will be required in the module to get the needed functionality.

Permissions and security

Security needs to be taken seriously in this module, so we need to define a couple of permissions that we can check to ensure that the user currently visiting the session inspector page has the correct access. These permissions are as follows.

  • inspect own user sessions - This will allow users to view the session inspector information for their own user accounts. Having a single permission for this means that the module can be enabled but not exposed to all users if not required.
  • inspect other user sessions - This permission can be given to admin users who need to view session information for other users. This permission should only be given to high level administrators and so it will be flagged as such.

The permissions will be tied into a custom access handler for every route we define. This will allow us to check that the user has a relevant permission, but also to ensure that the user visiting the page is the user who the page belongs to. It would do no good to just have these permissions as that fundamental check is not performed.

Controllers and routes

This module is pretty simple, so the only controller needed is the one needed to display a list of sessions to the user. A route and a local task will be created to inject this page into the user administration screens at the path /user/1/sessions.

The displaying of the list of sessions will be done as a table, since it is tabular data. We can change this in the future if we need to, but using a table means we don't need any extra styles of JavaScript to display the information.

We will also need to create an additional route for the deletion of sessions, which will be handled by a Drupal confirmation form so that we can check that the user really wanted to delete the session.

The module won't have any configuration options or screens for the MVP, so there are no configuration options required and therefore no forms needed to edit them.

Services

Aside from the custom access manager, we only need to create a single service for this module. This service will abstract away a lot of the database interactions with the sessions table as it makes no sense to bake these interactions into the controller for the sessions page.

The two methods we will need are:

  • getSessions() - Given a user account object this method will return the sessions currently associated with that user.
  • deactivateSession() - Given a session ID this will delete the session from the database table and render the session unavailable.

As the core session_manager service doesn't provide these methods we don't need to use that service here. We do, however, need to inject the database connection into this service since all of the interactions we need are directly with the database.

Writing Code

Now that we have a good idea of what parts are needed for this module we can start writing code. There are a bunch of YML files required for a module to operate, so let's make a start by adding them.

The first thing we need is a session_inspector.info.yml file so that Drupal can detect the module. Since the module will make direct use of user entities (for permission checking etc.) then it makes sense to also have this as a dependency. Other than that, there are no configuration pages or other settings that are really needed here. We could add a 'package' setting, but I don't think it's needed really.

name: Session Inspector
type: module
description: 'Allow your users to inspect and manage their sessions.'
core_version_requirement: ^8.8 || ^9

dependencies:
 - drupal:user

Next, we define the permissions. This needs a session_inspector.permissions.yml file to be created and for the two permissions to be defined. The "inspect other user sessions" is flagged with "restrict access: true" in order to show a warning to site administrators that the permission has security implications.

inspect own user sessions:
  title: 'Inspect and manage own user sessions'
  description: 'User to able to access and inspect their own session information.'
inspect other user sessions:
  title: 'Inspect and manage other users sessions'
  description: 'User is able to access and inspect and manage other users session information.'
  restrict access: true

Defining the services we need for the module is next, and we only have a couple of services to define in the session_inspector.services.yml file.

The session_inspector service is our link between the module and the sessions table in the database. We therefore pass the core database service as a dependency to this service.

The session_inspector.access_checker is an access check service that will be used to ensure that the permissions we defined above are also matched with users to ensure the correct access. Adding the tags setting in this way tells Drupal that is is an access check service and allows us to inform the routes we create about the check.

services:
  session_inspector:
    class: Drupal\session_inspector\SessionInspector
    arguments: ['@database']
  session_inspector.access_checker:
    class: Drupal\session_inspector\Access\SessionInspectorAccessCheck
    tags:
      - { name: access_check, applies_to: _session_inspector_access_check }

Speaking of routes, let's define those next and put them in the module's session_inspector.routing.yml file.

If you remember in the design section we only needed two routes in the module. One route is the controller for the session inspector page and the other is a route to perform the deletion through a confirmation form.

The main item of note here are that we have passed the "_session_inspector_access_check" access check service as a requirement of all routes that we define for the module. We also ensure that the "{user}" parameter in the path is sent to the controller and access checks as a full user entity.

session_inspector.manage:
  path: '/user/{user}/sessions'
  defaults:
    _title: 'Sessions'
    _controller: '\Drupal\session_inspector\Controller\UserSessionInspector::inspectSessionPage'
  requirements:
    _session_inspector_access_check: 'TRUE'
  options:
    parameters:
      user:
        type: entity:user
    _admin_route: TRUE

session_inspector.delete:
  path: '/user/{user}/sessions/{sid}/delete'
  defaults:
    _form: '\Drupal\session_inspector\Form\UserSessionDeleteForm'
    _title: 'Delete session'
  requirements:
    _session_inspector_access_check: 'TRUE'
  options:
    parameters:
      user:
        type: entity:user
    _admin_route: TRUE

Finally for the YML files is the session_inspector.links.task.yml file. This file is used to inject our sessions page into the users profile local tabs. There isn't a lot to note here, other than the fact that we give the menu tab a little weight to push it down the list of user menu tabs. It would otherwise randomly appear between other tabs so setting this value ensures the correct position.

session_inspector.manage:
  title: 'Sessions'
  route_name: session_inspector.manage
  base_route: entity.user.canonical
  weight: 20

With all that in place we can now write some PHP code.

I did think about adding every single line of code here, but I will instead pull out some of the more important parts and leave out things like interface definitions and service dependencies that the code has. If you want the source code to the project then you can grab it from drupal.org, since I have created the Session Inspector module there. You can also go directly to the source code of the Session Inspector module.

The SessionInspector service class contains two methods that can be used to grab a list of the users sessions, and allow a single session to be deleted. This is created at src/SessionInspector.php, inside the module.

The getSession() method uses the database connection (injected into the service) to perform a standard query against the sessions table using the user's ID as a condition. This returns an array of objects that contain the session data.

  public function getSessions(AccountInterface $account):array {
    /** @var \Drupal\Core\Database\Query\Select $query */
    $query = $this->database->select('sessions', 's');
    $query->fields('s', ['uid', 'sid', 'hostname', 'timestamp']);
    $query->condition('s.uid', $account->id());
    $query->orderBy('timestamp', 'DESC');

    return $query->execute()->fetchAll();
  }

The deactivateSession() method is even simpler. It just accepts a session ID and deletes it from the database.

  public function deactivateSession(string $session_id):void {
    $query = $this->database->delete('sessions');
    $query->condition('sid', $session_id);
    $query->execute();
  }

There are no access or permission checks in this service since these methods are concentrating on doing just one thing. The access checks are performed upstream from this code.

The UserSessionInspector controller is primarily used to show the user their session information. In the inspectSessionPage() action we call the getSessions() method from the SessionInspector service that was injected into the controller to get a list of the sessions for a particular user. The array is then passed into a loop to generate an array of rows, which we then pass to a table render array.

To simplify what the code is doing I have also abstracted some of the formatting into methods that also live within the constructor.  In order to convert the timestamp into a readable time format the code passes the timestamp value to a formatTimestamp() method, which returns a human readable date string. The formatHostname() method just returns the hostname without changing it.

The user and session IDs are passed to the formatDeleteLink() method to generate a link to the session_inspector.delete route so the user can delete a session from the list. This also adds a "data-test" attribute to the link in order to make things simpler for the unit tests.

  /**
   * Callback for the route 'session_inspector.manage'.
   *
   * @param \Drupal\user\UserInterface $user
   *   The user, as discerned from the path.
   *
   * @return array
   *   The renderable array for the page output.
   */
  public function inspectSessionPage(UserInterface $user) {
    $output = [];

    $output['description'] = [
      '#markup' => '<p>' . $this->t('Here is a list of sessions.') . '</p>',
    ];

    $sessions = $this->sessionInspector->getSessions($user);

    $rows = [];

    foreach ($sessions as $i => $session) {
      $rows[] = [
        $this->isCurrentSession($session->sid) ? 'YES' : '',
        $this->formatHostname($session->hostname),
        $this->formatTimestamp($session->timestamp),
        [
          'data' => $this->formatDeleteLink($user, $session->sid),
          'data-test' => ['session-operation-' . $i],
        ],
      ];
    }

    $output['output'] = [
      '#type' => 'table',
      '#header' => [
        $this->t('Current'),
        $this->t('hostname'),
        $this->t('timestamp'),
        $this->t('Operations'),
      ],
      '#rows' => $rows,
      '#attributes' => [
        'class' => [
          'sessions-table',
        ],
      ],
    ];

    return $output;
  }

An important part of the above code is the method to detect the current session in use by the user, called isCurrentSession().

In this method we use the session manager to get the ID of the current session. As this is not the ID stored in the database we must first hash the value using the Crypt::hashBase64() method. The resulting value can then match against the value in the database.

Here is the method used here.

  /**
   * Is a session ID the currently used session?
   *
   * @param string $sessionId
   *   The session ID to inspect.
   *
   * @return bool
   *   Returns true if the session ID is the currently used one.
   */
  public function isCurrentSession(string $sessionId): bool {
    return Crypt::hashBase64($this->sessionManager->getId()) === $sessionId;
  }

Putting this together gives us the following tab on the user profile page.

Screenshot of the list of sessions for a user, as created by the session inspector module.

The UserSessionDeleteForm form used on the session_inspector.delete route extends the Drupal ConfirmFormBase class. This base class is used to generate a confirmation form that contains confirmation and cancel buttons.

There is a lot of boiler plate code contained in this form, but the two main methods of interest are getCancelUrl() and submitForm(). The getCancelUrl() is used to generate the link back to the page the user came from, which is used when the user clicks cancel on the confirm form.

When the user clicks the confirm button the submitForm() method is used, which deletes the session using the session_inspector service and redirects the user back to their sessions page.

class UserSessionDeleteForm extends ConfirmFormBase {
//... code removed for brevity.
  public function getCancelUrl() {
    return new Url('session_inspector.manage', ['user' => $this->user->id()]);
  }

  public function submitForm(array &$form, FormStateInterface $form_state) {
    $this->sessionInspector->deactivateSession($this->sessionId);
    $form_state->setRedirectUrl($this->getCancelUrl());
  }

}

Finally, we can now look at the access check service class called SessionInspectorAccessCheck. This is a deliberately opinionated class that returns either an allowed or forbidden depending on what the user's permissions are.

The access check works in the following way:

  • If the user has the permission "inspect other user sessions" they they are allowed through without any further checks.
  • If the user has the permission "inspect own users sessions" then a check is also made to ensure that the $user ID (from the path) is the same as the $account ID (from the currently logged in user).

This does technically mean that a user with the "inspect other user sessions" can see their own session without having the "inspect own users sessions" permission. I don't think this matters in the bigger picture of permissions though.

Since the access class is quite small I'll add the entire thing here.

<?php

namespace Drupal\session_inspector\Access;

use Drupal\user\UserInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Access\AccessResult;

/**
 * Checks access for displaying the session inspection and management page.
 */
class SessionInspectorAccessCheck implements AccessInterface {

  /**
   * Session inspector access check.
   *
   * @param \Drupal\user\UserInterface $user
   *   The user who the sessions page belongs to.
   * @param \Drupal\Core\Session\AccountInterface $account
   *   Run access checks for this account.
   *
   * @return \Drupal\Core\Access\AccessResultInterface
   *   The access result.
   */
  public function access(UserInterface $user, AccountInterface $account) {
    if ($account->hasPermission('inspect other user sessions')) {
      return AccessResult::allowed();
    }

    if ($account->hasPermission('inspect own user sessions') && $account->id() == $user->id()) {
      return AccessResult::allowed();
    }

    return AccessResult::forbidden();
  }

}

As I mentioned before, I have left out a lot of the boiler plate code in the code examples above. So whilst we are using injected services, I don't actually show them being injected. There are also a number of interface classes that are required (especially in the confirmation form) that I haven't included. Outside of this boiler plate code there isn't much else involved here.

Testing

I haven't mentioned testing yet, but pretty much any module needs a couple of tests. This is especially true for this module as we need to prove that no matter what change we make to the services the permissions are always as expected.

To test this module we need to extend the core BrowserTestBase class so that we can test users interacting with the site as they normally would. Using this class allows us to install the Session Inspector module, test the module permissions, and test the deletion of sessions from the sessions table.

We can therefore break the tests down in the following way.

  • Test that an anonymous user cannot reach the session page.
  • Test that when a user deletes their current session they are logged out.
  • Test that when a user visits another user's sessions page they get access denied.

The middle test can also contain some assertions about what the user sees when they visit the session page.

Here is the test class in full.

<?php

namespace Drupal\Tests\session_inspector\Functional;

use Drupal\Tests\BrowserTestBase;

/**
 * Test the functionality of the session inspector module.
 *
 * @group session_inspector
 */
class SessionInspectorTest extends BrowserTestBase {

  /**
   * Modules to enable.
   *
   * @var array
   */
  protected static $modules = ['session_inspector'];

  /**
   * The theme to install as the default for testing.
   *
   * @var string
   */
  public $defaultTheme = 'stark';

  /**
   * Test that an anonymous user can't access a users session page.
   */
  public function testAnonUserCanNotReachTheSessionPage() {
    $this->drupalGet('user/1/sessions');
    $this->assertSession()->responseContains('Access denied');
  }

  /**
   * Test that a user can inspect and delete their own sessions.
   */
  public function testUserWithPermissionsCanInspectSessions() {
    $user = $this->createUser(['inspect own user sessions']);
    $this->drupalLogin($user);

    $this->drupalGet('user/' . $user->id() . '/sessions');

    $this->assertSession()->responseContains('CURRENT');
    $this->assertSession()->responseContains('HOSTNAME');
    $this->assertSession()->responseContains('TIMESTAMP');
    $this->assertSession()->responseContains('OPERATIONS');

    // Delete on the delete.
    $this->click('[data-test="session-operation-0"] a');

    // Click confirm.
    $this->click('[id="edit-submit"]');

    // The response is an access denied as the user is logged out.
    $this->assertSession()->responseContains('Access denied');

    // Ensure that we are not logged in anymore.
    $this->drupalGet('user/' . $user->id() . '/edit');
    $this->assertSession()->responseContains('Access denied');
  }

  /**
   * Test that a user cannot access another users sessions page.
   */
  public function testUserCannotAccessAnotherUsersSessionPage() {
    // Create users.
    $user = $this->createUser(['inspect own user sessions']);
    $anotherUser = $this->createUser(['inspect own user sessions']);

    // Log in as frst user.
    $this->drupalLogin($user);

    // Access down session page.
    $this->drupalGet('user/' . $user->id() . '/sessions');

    // Attempt to access other session page.
    $this->drupalGet('user/' . $anotherUser->id() . '/edit');
    $this->assertSession()->responseContains('Access denied');
  }

}

Running these tests gives us the confidence that the permissions and access checks we have set up work correctly. More tests can also be added in the future to cover other aspects of the module.

Future Plans

What I have created so far is essentially the MVP of the Drupal Session Inspector module. Aside from tweaking some of the words there are a few more things we can do to improve the module and take things further.

Here are some thoughts about future plans for the module.

IP address geolocation

At the moment we only have the IP address printed out on screen. I mentioned in the design phase that abstracting this away from users might be a good plan since only subset of users will actually understand what it is. We should still think about geolocating this into a location as this will provide useful information.

Integrating an IP geolocation service into the module will allow IP addresses to be translated into a location. At a minimum this would be the country of origin, but we can also include things like city information if the service used contains that data.

Since there are a few ways to get this information the geolocation needs to be created as a plugin. This would give developers the ability to swap in their own plugins for the geolocation.

Browser information

Drupal doesn't store browser information of a session so we would need to hook into the session creation systems to inject that information into the sessions table. There is a field in the sessions table that allows metadata about the session to be stored so I think we could add our own information to that. If not, we can always create an addition table to store this information.

The information about browser and operating system is certainly discoverable, but we could also make this into a service or a plugin so that developers can change what information is shown to users. Showing a user a user agent tag is probably not going to be useful since they can be a little messy. Instead, we should show the user the browser name they were using and the main type of operating system. This means that we need to parse the user agent information.

Formatting and theming

The controller for this module has a number of internal methods that will do things like convert a timestamp into something more readable. These methods don't need to live in the controller and could be abstracted out into another service or theme.

I have left these methods in the controller for now as it didn't make sense to abstract them quite yet. When things like browser information and IP address geolocation are included then it does make sense to do this. Without these items in place I would just be creating a service in case I needed it, which would add complexity to the module.

Single sign on

The deletion mechanism for this module is fine for locally stored Drupal sessions, but it wouldn't work when we use single sign on systems. This is because the session information is actually stored outside of Drupal and we just create a local session within Drupal for the single sign on system. If we delete the record in the sessions table then it might just be recreated again if the single sign on session still have a valid session.

It is impossible to adapt the session inspector module for every single sign on service around, but it should be possible to trigger events and hooks at the appropriate time to allow single sign on services to be contacted. This means that we simply pass the responsibility of the session removal into the other system rather than create dependencies for other codebases in this simple module.

Other module interactions

Whilst this module is simple, it can expose functionality created by other modules. A prime example is the masquerade module, which allows site administrators the ability to log into the site using another user account. As the sessions table essentially contains a new session for the user, our module would interpret that as an additional session.

To work around this we would need to make sure that when showing the user session information we exclude any module that might leave footprints in the sessions table.

Alternatively, we could also allow this information to be seen. We are trying to create a module that will show all session information connected to a user, so masquerade sessions might also need to be a part of that.

Download The Session Inspector Module

If you've read this and are keen to have a look at the module then you'll be pleased to know that I have released it as a full module on drupal.org. You can take a look at the Session Inspector module there or include it into your projects using composer.

Let me know if you found this article useful, or if the session inspector module and found it useful. I have created articles in the past that went through module creation and have received questions asking for more detail (or even a downloadable version). If this sort of thing is useful then I'll keep it up. The module might not be a full module on drupal.org, but I might put the example code on GitHub so that it can be downloaded.

Add new comment

The content of this field is kept private and will not be shown publicly.