Commit 9831e4f3 authored by Peter Wolanin's avatar Peter Wolanin
Browse files

Issue #3150293 by pwolanin: Allow a claim for user name or uuid instead of uid to authenticate.

               Also fixes some uses of deprecated functions in tests.
parent 95d2db85
Loading
Loading
Loading
Loading
+5 −2
Original line number Diff line number Diff line
@@ -23,8 +23,11 @@ Go to /admin/config/system/jwt to pick the key to be used.

When creating a JWT to send, the iat and exp claims should be included.

The namespaced claim drupal / uid is used by jwt_auth_consumer to determine the
user account to be used when authenticated.
The namespaced claim "drupal / uid" is used by jwt_auth_consumer to determine the
user account to be used when authenticated. You can also use a user uuid or
username with claims "drupal / uuid" or "drupal / name". The claims are
checked in the order listed here, and the first one that's populated is
used to determine the user.

## Request Header

+53 −17
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\jwt\Authentication\Event\JwtAuthValidateEvent;
use Drupal\jwt\Authentication\Event\JwtAuthValidEvent;
use Drupal\jwt\Authentication\Event\JwtAuthEvents;
use Drupal\jwt\JsonWebToken\JsonWebTokenInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
@@ -41,28 +42,65 @@ class JwtAuthConsumerSubscriber implements EventSubscriberInterface {
  }

  /**
   * Validates that a uid is present in the JWT.
   * Find and load the user for a JWT.
   *
   * This validates the format of the JWT and validate the uid is a
   * valid uid in the system.
   * @param \Drupal\jwt\JsonWebToken\JsonWebTokenInterface $token
   *   The JWT.
   *
   * @return array
   *   The user and reason if no user was loaded.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  protected function loadUserForJwt(JsonWebTokenInterface $token): array {
    foreach (['uid', 'uuid', 'name'] as $id_type) {
      $id = $token->getClaim(['drupal', $id_type]);
      if ($id !== NULL) {
        break;
      }
    }
    if ($id === NULL) {
      return [
        NULL,
        'No Drupal uid, uuid, or name was provided in the JWT payload.',
      ];
    }
    $user_storage = $this->entityTypeManager->getStorage('user');
    if ($id_type === 'uid') {
      $user = $user_storage->load($id);
    }
    else {
      $user = current($user_storage->loadByProperties([$id_type => $id]));
    }
    if (!$user) {
      return [NULL, 'User does not exist.'];
    }
    if ($user->isBlocked()) {
      return [NULL, 'User is blocked.'];
    }
    return [$user, NULL];
  }

  /**
   * Validates that a uid, uuid, or name is present in the JWT.
   *
   * This validates the format of the JWT and validate the uid, uuid, or name
   * corresponds to a valid user in the system.
   *
   * @param \Drupal\jwt\Authentication\Event\JwtAuthValidateEvent $event
   *   A JwtAuth event.
   */
  public function validate(JwtAuthValidateEvent $event) {
    $token = $event->getToken();
    $uid = $token->getClaim(['drupal', 'uid']);
    if ($uid === NULL) {
      $event->invalidate('No Drupal uid was provided in the JWT payload.');
      return;
    }
    $user = $this->entityTypeManager->getStorage('user')->load($uid);
    if ($user === NULL) {
      $event->invalidate('No UID exists.');
      return;
    [$user, $reason] = $this->loadUserForJwt($token);
    if (!$user) {
      $event->invalidate($reason);
    }
    if ($user->isBlocked()) {
      $event->invalidate('User is blocked.');
    elseif (!$token->getClaim(['drupal', 'uid'])) {
      // Set the uid claim to simplify the code path in other subscribers and
      // to make the loadUser step more efficient.
      $token->setClaim(['drupal', 'uid'], (int) $user->id());
    }
  }

@@ -74,9 +112,7 @@ class JwtAuthConsumerSubscriber implements EventSubscriberInterface {
   */
  public function loadUser(JwtAuthValidEvent $event) {
    $token = $event->getToken();
    $user_storage = $this->entityTypeManager->getStorage('user');
    $uid = $token->getClaim(['drupal', 'uid']);
    $user = $user_storage->load($uid);
    [$user] = $this->loadUserForJwt($token);
    $event->setUser($user);
  }

+5 −2
Original line number Diff line number Diff line
@@ -12,8 +12,11 @@ string associated with a Drupal user account.
When creating a JWT, the iat and exp claims must be included. The exp cannot be more than
24 hours later than the iat value.

Like the main jwt module, the namespaced claim drupal / uid is used to indicate the user
account to be used when authenticated. This must match the uid associated with the key ID.
Like the jwt_auth_consumer module, the namespaced claim drupal / uid is used to
determine the user account to be used when authenticated. You can also use a user
uuid or username with claims "drupal / uuid" or "drupal / name". The claims are
checked in the order listed here, and the first one that's populated is used to
determine the user. This user uid must match the uid associated with the key ID.

## Request Header

+51 −8
Original line number Diff line number Diff line
@@ -13,7 +13,7 @@ use Symfony\Component\HttpFoundation\Request;
use Firebase\JWT\JWT;

/**
 * Class UsersJwtAuth.
 * Authentication provider UsersJwtAuth.
 */
class UsersJwtAuth implements AuthenticationProviderInterface {

@@ -109,15 +109,58 @@ class UsersJwtAuth implements AuthenticationProviderInterface {
    if ($header->alg !== $key->alg) {
      return $this->debugLog('Bad header alg', NULL, $payload, $key);
    }
    if (empty($payload->drupal->uid) || (int) $payload->drupal->uid !== $key->uid) {
      return $this->debugLog('Bad uid claim', NULL, $payload, $key);
    }
    /** @var \Drupal\user\UserInterface $user */
    $user = $this->entityTypeManager->getStorage('user')->load($key->uid);
    if ($user && !$user->isBlocked()) {
    [$user, $reason] = $this->loadUserForJwt($payload);
    if (!$user) {
      return $this->debugLog($reason, NULL, $payload, $key);
    }
    if ($key->uid !== (int) $user->id()) {
      return $this->debugLog('User uid does not match key uid', NULL, $payload, $key, $user);
    }
    return $user;
  }
    return $this->debugLog('Bad user', NULL, $payload, $key, $user);

  /**
   * Find and load the user for a JWT.
   *
   * @todo Unify this code and the code in JwtAuthConsumerSubscriber.
   *
   * @param object $payload
   *   The JWT claims.
   *
   * @return array
   *   The user and reason if no user was loaded.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  protected function loadUserForJwt(object $payload): array {
    foreach (['uid', 'uuid', 'name'] as $id_type) {
      if (isset($payload->drupal->{$id_type})) {
        $id = $payload->drupal->{$id_type};
        break;
      }
    }
    if ($id === NULL) {
      return [
        NULL,
        'No Drupal uid, uuid, or name was provided in the JWT payload.',
      ];
    }
    $user_storage = $this->entityTypeManager->getStorage('user');
    if ($id_type === 'uid') {
      $user = $user_storage->load($id);
    }
    else {
      $user = current($user_storage->loadByProperties([$id_type => $id]));
    }
    if (!$user) {
      return [NULL, 'User does not exist.'];
    }
    if ($user->isBlocked()) {
      return [NULL, 'User is blocked.'];
    }
    return [$user, NULL];
  }

  /**
+77 −51
Original line number Diff line number Diff line
@@ -18,7 +18,7 @@ class FormsTest extends BrowserTestBase {
   *
   * @var array
   */
  public static $modules = ['users_jwt', 'block'];
  protected static $modules = ['users_jwt', 'block'];

  /**
   * {@inheritdoc}
@@ -42,10 +42,11 @@ class FormsTest extends BrowserTestBase {
  /**
   * {@inheritdoc}
   */
  protected function setUp() {
  protected function setUp(): void {
    parent::setUp();
    $this->drupalPlaceBlock('local_actions_block');
    $this->adminUser = $this->drupalCreateUser(['administer site configuration', 'administer users']);
    $perms = ['administer site configuration', 'administer users'];
    $this->adminUser = $this->drupalCreateUser($perms);
    $this->user = $this->drupalCreateUser(['access content']);
    $this->drupalLogin($this->user);
  }
@@ -56,20 +57,21 @@ class FormsTest extends BrowserTestBase {
  public function testForms() {
    // Loading another user's page should fail.
    $this->drupalGet(Url::fromRoute('users_jwt.key_list', ['user' => $this->adminUser->id()]));
    $this->assertResponse(403);
    $this->assertSession()->statusCodeEquals(403);
    $this->drupalGet(Url::fromRoute('users_jwt.key_list', ['user' => $this->user->id()]));
    $this->assertResponse(200);
    $this->assertText('No keys found.');
    $this->assertSession()->statusCodeEquals(200);
    $this->assertSession()->pageTextContains('No keys found.');
    $this->clickLink('Generate Key');
    $this->assertText('When you click the button, a new key will be generated');
    $this->assertSession()->pageTextContains('When you click the button, a new key will be generated');
    $this->submitForm([], 'Generate');
    // The test browser sees the response content.
    $generated_private_key = $this->getSession()->getPage()->getContent();
    self::assertNotFalse(\mb_strpos($generated_private_key, '-----BEGIN PRIVATE KEY-----'));
    $this->drupalGet(Url::fromRoute('users_jwt.key_list', ['user' => $this->user->id()]));
    $this->assertNoText('No keys found.');
    $this->assertText('-----BEGIN PUBLIC KEY-----');
    $this->assertCacheTag('users_jwt:' . $this->user->id());
    $this->assertSession()->pageTextnotContains('No keys found.');
    $this->assertSession()->pageTextContains('-----BEGIN PUBLIC KEY-----');
    $expected_cache_tag = 'users_jwt:' . $this->user->id();
    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', $expected_cache_tag);
    /** @var \Drupal\users_jwt\UsersJwtKeyRepositoryInterface $key_repository */
    $key_repository = $this->container->get('users_jwt.key_repository');
    $keys = $key_repository->getUsersKeys($this->user->id());
@@ -78,17 +80,20 @@ class FormsTest extends BrowserTestBase {
    // Sleep to make sure the time changes for the next key ID.
    sleep(1);
    $this->clickLink('Add Key');
    $path = drupal_get_path('module', 'users_jwt') . '/tests/fixtures/users_jwt_rsa1-public.pem';
    /** @var \Drupal\Core\Extension\ExtensionPathResolver $path_resolver */
    $path_resolver = $this->container->get('extension.path.resolver');
    $module_path = $path_resolver->getPath('module', 'users_jwt');
    $path = $module_path . '/tests/fixtures/users_jwt_rsa1-public.pem';
    $public_key = file_get_contents($path);
    $path = drupal_get_path('module', 'users_jwt') . '/tests/fixtures/users_jwt_rsa1-private.pem';
    $path = $module_path . '/tests/fixtures/users_jwt_rsa1-private.pem';
    $private_key1 = file_get_contents($path);
    $path = drupal_get_path('module', 'users_jwt') . '/tests/fixtures/users_jwt_rsa2-private.pem';
    $path = $module_path . '/tests/fixtures/users_jwt_rsa2-private.pem';
    $private_key2 = file_get_contents($path);
    $edit = [
      'pubkey' => $this->randomString(),
    ];
    $this->submitForm($edit, 'Save');
    $this->assertText('This does not look like a PEM formatted RSA public key');
    $this->assertSession()->pageTextContains('This does not look like a PEM formatted RSA public key');
    $edit = [
      'pubkey' => $public_key,
    ];
@@ -102,14 +107,25 @@ class FormsTest extends BrowserTestBase {
    // Allowed to access the normal user's keys page.
    $url = Url::fromRoute('users_jwt.key_list', ['user' => $this->user->id()]);
    $this->drupalGet($url);
    $this->assertResponse(200);
    $this->assertSession()->statusCodeEquals(200);
    $this->drupalLogout();
    $iat = \Drupal::time()->getCurrentTime();
    $variants = [
      'uid' => $this->user->id(),
      'uuid' => $this->user->uuid(),
      'name' => $this->user->getAccountName(),
    ];
    $extra = [
      'uid' => ['uuid', 'name'],
      'uuid' => ['name', 'anything'],
      'name' => ['something'],
    ];
    foreach ($variants as $id_type => $id_value) {
      $iat = \Drupal::time()->getRequestTime();
      $good_payload = [
        'iat' => $iat,
        'exp' => $iat + 1000,
        'drupal' => [
        'uid' => $this->user->id(),
          $id_type => $id_value,
        ],
      ];
      // Verify requests work with the generated/submitted keys.
@@ -121,26 +137,36 @@ class FormsTest extends BrowserTestBase {
        self::assertNotEmpty($token);
        $headers = [$header_name => 'UsersJwt ' . $token];
        $this->drupalGet($url, [], $headers);
      $this->assertResponse(200);
        $this->assertSession()->statusCodeEquals(200);
        $token = JWT::encode($good_payload, $private_key1, 'RS256', $submitted_key->id);
        $headers = [$header_name => 'UsersJwt ' . $token];
        $this->drupalGet($url, [], $headers);
      $this->assertResponse(200);
        $this->assertSession()->statusCodeEquals(200);
        // Add extra claims that should be ignored.
        $extra_payload = $good_payload;
        foreach ($extra[$id_type] as $key) {
          $extra_payload['drupal'][$key] = $this->randomMachineName();
        }
        $token = JWT::encode($extra_payload, $generated_private_key, 'RS256', $generated_key->id);
        $headers = [$header_name => 'UsersJwt ' . $token];
        $this->drupalGet($url, [], $headers);
        $this->assertSession()->statusCodeEquals(200);
        // Invalid key ID.
        $token = JWT::encode($good_payload, $private_key1, 'RS256', 'wxyz');
        $headers = [$header_name => 'UsersJwt ' . $token];
        $this->drupalGet($url, [], $headers);
      $this->assertResponse(403);
        $this->assertSession()->statusCodeEquals(403);
        // Invalid private key.
        $token = JWT::encode($good_payload, $private_key2, 'RS256', $submitted_key->id);
        $headers = [$header_name => 'UsersJwt ' . $token];
        $this->drupalGet($url, [], $headers);
      $this->assertResponse(403);
        $this->assertSession()->statusCodeEquals(403);
        // Invalid private key, public page.
        $token = JWT::encode($good_payload, $private_key2, 'RS256', $submitted_key->id);
        $headers = [$header_name => 'UsersJwt ' . $token];
        $this->drupalGet('<front>', [], $headers);
      $this->assertResponse(200);
        $this->assertSession()->statusCodeEquals(200);
      }
    }
  }

Loading