Source of file Tilmeld.php

Size: 16,627 Bytes - Last Modified: 2018-05-11T23:38:05+00:00

/Users/hperrin/repos/nymph/tilmeld.org/../tilmeld-server/src/Tilmeld.php

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
<?php namespace Tilmeld;

use SciActive\Hook;
use Nymph\Nymph;
use Tilmeld\Entities\User;
use Tilmeld\Entities\Group;

/**
 * Tilmeld main class.
 *
 * Provides an Nymph based user and group manager.
 *
 * @license https://www.apache.org/licenses/LICENSE-2.0
 * @author Hunter Perrin <hperrin@gmail.com>
 * @copyright SciActive.com
 * @link http://tilmeld.org/
 */
class Tilmeld {
  const VERSION = '1.0.0-beta.1';

  const NO_ACCESS = 0;
  const READ_ACCESS = 1;
  const WRITE_ACCESS = 2;
  const FULL_ACCESS = 4;

  /**
   * The Tilmeld config array.
   *
   * @var array
   * @access public
   */
  public static $config;

  /**
   * The currently logged in user.
   *
   * @var \Tilmeld\Entities\User|null
   * @access public
   */
  public static $currentUser = null;

  /**
   * Check to see if the current user has an ability.
   *
   * If $ability is null, it will check to see if a user is currently logged
   * in.
   *
   * @param string $ability The ability.
   * @return bool True or false.
   */
  public static function gatekeeper($ability = null) {
    if (!isset(self::$currentUser)) {
      return false;
    }
    return self::$currentUser->gatekeeper($ability);
  }

  /**
   * Apply configuration to Tilmeld.
   *
   * $config should be an associative array of Tilmeld configuration. Use the
   * following form:
   *
   * [
   *   'setup_url' => 'http://example.com/tilmeld/',
   *   'create_admin' => false
   * ]
   *
   * @param array $config An associative array of Tilmeld's configuration.
   */
  public static function configure($config = []) {
    $defaults = include dirname(__DIR__).'/conf/defaults.php';
    self::$config = array_replace($defaults, $config);

    // Set up access control hooks when Nymph is called.
    if (!isset(Nymph::$driver)) {
      throw new Exception('Tilmeld can\'t be configured before Nymph.');
    }
    HookMethods::setup();
    self::authenticate();
  }

  /**
   * Add selectors to a list of options and selectors which will limit results
   * to only entities the user has access to.
   *
   * @param array &$optionsAndSelectors The options and selectors of the query.
   * @param \Tilmeld\Entities\User|null $user The user to add access controls
   *                                          for. If null, uses the current
   *                                          user.
   */
  public static function addAccessControlSelectors(
      &$optionsAndSelectors,
      $user = null
  ) {
    if (!isset($user)) {
      $user = self::$currentUser;
    }

    if (isset($user) && $user->gatekeeper('system/admin')) {
      // The user is a system admin, so they can see everything.
      return;
    }

    if (!isset($optionsAndSelectors[0])) {
      throw new Exception('No options in argument.');
    } elseif (isset($optionsAndSelectors[0]['class'])
        && (
          $optionsAndSelectors[0]['class'] === '\Tilmeld\Entities\User'
          || $optionsAndSelectors[0]['class'] === '\Tilmeld\Entities\Group'
          || $optionsAndSelectors[0]['class'] === 'Tilmeld\Entities\User'
          || $optionsAndSelectors[0]['class'] === 'Tilmeld\Entities\Group'
        )
      ) {
      // They are requesting a user/group. Always accessible for reading.
      return;
    }

    if ($user === null) {
      $optionsAndSelectors[] = ['|',
        // Other access control is sufficient.
        'gte' => ['acOther', Tilmeld::READ_ACCESS],
        // The user and group are not set.
        ['&',
          '!isset' => ['user', 'group']
        ]
      ];
    } else {
      $selector = ['|',
        // Other access control is sufficient.
        'gte' => ['acOther', Tilmeld::READ_ACCESS],
        // The user and group are not set.
        ['&',
          '!isset' => [
            'user',
            'group'
          ]
        ],
        // It is owned by the user.
        ['&',
          'ref' => ['user', $user],
          'gte' => ['acUser', Tilmeld::READ_ACCESS]
        ],
        // The user is listed in acRead, acWrite, or acFull.
        'ref' => [
          ['acRead', $user],
          ['acWrite', $user],
          ['acFull', $user]
        ]
      ];
      $groupRefs = [];
      $acRefs = [];
      if (isset($user->group) && isset($user->group->guid)) {
        // It belongs to the user's primary group.
        $groupRefs[] = ['group', $user->group];
        // User's primary group is listed in acRead, acWrite, or acFull.
        $acRefs[] = ['acRead', $user->group];
        $acRefs[] = ['acWrite', $user->group];
        $acRefs[] = ['acFull', $user->group];
      }
      foreach ($user->groups as $curSecondaryGroup) {
        if (isset($curSecondaryGroup) && isset($curSecondaryGroup->guid)) {
          // It belongs to the user's secondary group.
          $groupRefs[] = ['group', $curSecondaryGroup];
          // User's secondary group is listed in acRead, acWrite, or acFull.
          $acRefs[] = ['acRead', $curSecondaryGroup];
          $acRefs[] = ['acWrite', $curSecondaryGroup];
          $acRefs[] = ['acFull', $curSecondaryGroup];
        }
      }
      foreach ($user->getDescendantGroups() as $curDescendantGroup) {
        if (isset($curDescendantGroup) && isset($curDescendantGroup->guid)) {
          // It belongs to the user's secondary group.
          $groupRefs[] = ['group', $curDescendantGroup];
          // User's secondary group is listed in acRead, acWrite, or acFull.
          $acRefs[] = ['acRead', $curDescendantGroup];
          $acRefs[] = ['acWrite', $curDescendantGroup];
          $acRefs[] = ['acFull', $curDescendantGroup];
        }
      }
      // All the group refs.
      if (!empty($groupRefs)) {
        $selector[] = ['&',
          'gte' => ['acGroup', Tilmeld::READ_ACCESS],
          ['|',
            'ref' => $groupRefs
          ]
        ];
      }
      // All the acRead, acWrite, and acFull refs.
      if (!empty($acRefs)) {
        $selector[] = ['|',
          'ref' => $acRefs
        ];
      }
      $optionsAndSelectors[] = $selector;
    }
  }

  /**
   * Check an entity's permissions for a user.
   *
   * This will check the AC (Access Control) properties of the entity. These
   * include the following properties:
   *
   * - acUser
   * - acGroup
   * - acOther
   * - acRead
   * - acWrite
   * - acFull
   *
   * "acUser" refers to the entity's owner, "acGroup" refers to all users in the
   * entity's group and all ancestor groups, and "acOther" refers to any user
   * who doesn't fit these descriptions.
   *
   * Each of these properties should be either NO_ACCESS, READ_ACCESS,
   * WRITE_ACCESS, or FULL_ACCESS.
   *
   * - NO_ACCESS - the user has no access to the entity.
   * - READ_ACCESS, the user has read access to the entity.
   * - WRITE_ACCESS, the user has read and write access to the entity, but can't
   *   delete it, change its access controls, or change its ownership.
   * - FULL_ACCESS, the user has read, write, and delete access to the entity,
   *   as well as being able to manage its access controls and ownership.
   *
   * These properties defaults to:
   *
   * - acUser = Tilmeld::FULL_ACCESS
   * - acGroup = Tilmeld::READ_ACCESS
   * - acOther = Tilmeld::NO_ACCESS
   *
   * "acRead", "acWrite", and "acFull" are arrays of users and/or groups that
   * also have those permissions.
   *
   * Only users with FULL_ACCESS have the ability to change any of the ac*,
   * user, and group properties.
   *
   * The following conditions will result in different checks, which determine
   * whether the check passes:
   *
   * - The user has the "system/admin" ability. (Always true.)
   * - It is a user or group. (True for READ_ACCESS or Tilmeld admins.)
   * - The entity has no "user" and no "group". (Always true.)
   * - No user is logged in. (Check other AC.)
   * - The entity is the user. (Always true.)
   * - It is the user's primary group. (True for READ_ACCESS.)
   * - The user or its groups are listed in "acRead". (True for READ_ACCESS.)
   * - The user or its groups are listed in "acWrite". (True for READ_ACCESS and
   *   WRITE_ACCESS.)
   * - The user or its groups are listed in "acFull". (Always true.)
   * - Its "user" is the user. (It is owned by the user.) (Check user AC.)
   * - Its "group" is the user's primary group. (Check group AC.)
   * - Its "group" is one of the user's secondary groups. (Check group AC.)
   * - Its "group" is a descendant of one of the user's groups. (Check group
   *   AC.)
   * - None of the above. (Check other AC.)
   *
   * @param object &$entity The entity to check.
   * @param int $type The lowest level of permission to consider a pass. One of
   *                  Tilmeld::READ_ACCESS, Tilmeld::WRITE_ACCESS, or
   *                  Tilmeld::FULL_ACCESS.
   * @param \Tilmeld\Entities\User|null $user The user to check permissions for.
   *                                          If null, uses the current user. If
   *                                          false, checks for public access.
   * @return bool Whether the current user has at least $type permission for the
   *              entity.
   */
  public static function checkPermissions(
      &$entity,
      $type = Tilmeld::READ_ACCESS,
      $user = null
  ) {
    // Only works for entities.
    if (!is_object($entity) || !is_callable([$entity, 'is'])) {
      return false;
    }

    // Calculate the user.
    if ($user === null) {
      $userOrNull = self::$currentUser;
      $userOrEmpty = User::current(true);
    } elseif ($user === false) {
      $userOrNull = null;
      $userOrEmpty = User::factory();
    } else {
      $userOrNull = $userOrEmpty = $user;
    }

    if ($userOrEmpty->gatekeeper('system/admin')) {
      return true;
    }

    // Users and groups are always readable. Editable by Tilmeld admins.
    if ((
          is_a($entity, '\Tilmeld\Entities\User')
          || is_a($entity, '\Tilmeld\Entities\Group')
          || is_a($entity, '\SciActive\HookOverride_Tilmeld_Entities_User')
          || is_a($entity, '\SciActive\HookOverride_Tilmeld_Entities_Group')
        )
        && (
          $type === Tilmeld::READ_ACCESS
          || $userOrEmpty->gatekeeper('tilmeld/admin')
        )
      ) {
      return true;
    }

    // Entities with no owners are always editable.
    if (!isset($entity->user) && !isset($entity->group)) {
      return true;
    }

    // Load access control, since we need it now...
    $acUser = $entity->acUser ?? Tilmeld::FULL_ACCESS;
    $acGroup = $entity->acGroup ?? Tilmeld::READ_ACCESS;
    $acOther = $entity->acOther ?? Tilmeld::NO_ACCESS;

    if ($userOrNull === null) {
      return ($acOther >= $type);
    }

    // Check if the entity is the user.
    if ($userOrEmpty->is($entity)) {
      return true;
    }

    // Check if the entity is the user's group. Always readable.
    if (isset($userOrEmpty->group)
        && is_callable([$userOrEmpty->group, 'is'])
        && $userOrEmpty->group->is($entity)
        && $type === Tilmeld::READ_ACCESS
      ) {
      return true;
    }

    // Calculate all the groups the user belongs to.
    $allGroups = isset($userOrEmpty->group) ? [$userOrEmpty->group] : [];
    $allGroups = array_merge($allGroups, $userOrEmpty->groups);
    $allGroups = array_merge($allGroups, $userOrEmpty->getDescendantGroups());

    // Check access ac properties.
    $checks = [
      ['type' => Tilmeld::FULL_ACCESS, 'array' => (array) $entity->acFull],
      ['type' => Tilmeld::WRITE_ACCESS, 'array' => (array) $entity->acWrite],
      ['type' => Tilmeld::READ_ACCESS, 'array' => (array) $entity->acRead]
    ];
    foreach ($checks as $curCheck) {
      if ($type <= $curCheck['type']) {
        if ($userOrEmpty->inArray($curCheck['array'])) {
          return true;
        }
        foreach ($allGroups as $curGroup) {
          if (is_callable([$curGroup, 'inArray'])
              && $curGroup->inArray($curCheck['array'])
            ) {
            return true;
          }
        }
      }
    }

    // Check ownership ac properties.
    if (is_callable([$entity->user, 'is'])
        && $entity->user->is($userOrNull)
      ) {
      return ($acUser >= $type);
    }
    if (is_callable([$entity->group, 'is'])
        && $entity->group->inArray($allGroups)
      ) {
      return ($acGroup >= $type);
    }
    return ($acOther >= $type);
  }

  /**
   * Fill session user data.
   *
   * Also sets the default timezone to the user's timezone.
   *
   * @param \Tilmeld\Entities\User $user The user.
   */
  public static function fillSession($user) {
    self::$currentUser = $user;
    date_default_timezone_set($user->getTimezone());
    self::$currentUser->updateDataProtection();
  }

  /**
   * Validate and extract the user from a token.
   *
   * @param string $token The authentication token.
   * @return \Tilmeld\Entities\User|bool The user on success, false on failure.
   */
  public static function extractToken($token) {
    $extract = self::$config['jwt_extract']($token);
    if (!$extract) {
      return false;
    }
    $guid = $extract['guid'];

    $user = Nymph::getEntity(
        ['class' => '\Tilmeld\Entities\User'],
        ['&',
          'guid' => $guid
        ]
    );
    if (!$user || !$user->guid || !$user->enabled) {
      return false;
    }

    return $user;
  }

  /**
   * Check for a TILMELDAUTH cookie, and, if set, authenticate from it.
   *
   * @return bool True if a user was authenticated, false on any failure.
   */
  public static function authenticate() {
    if (empty($_COOKIE['TILMELDAUTH'])) {
      return false;
    }
    $setupUrlParts = parse_url(self::$config['setup_url']);
    $setupHost = $setupUrlParts['host'] .
      ($setupUrlParts['port'] ? ':'.$setupUrlParts['port'] : '');
    if ($_SERVER['HTTP_HOST'] === $setupHost
        && $_SERVER['REQUEST_URI'] === $setupUrlParts['path']
      ) {
      // The request is for the setup app, so don't check for the XSRF token.
      $extract = self::$config['jwt_extract']($_COOKIE['TILMELDAUTH']);
    } else {
      // The request is for something else, so check for a valid XSRF token.
      if (empty($_SERVER['HTTP_X_XSRF_TOKEN'])) {
        return false;
      }

      $extract = self::$config['jwt_extract'](
          $_COOKIE['TILMELDAUTH'],
          $_SERVER['HTTP_X_XSRF_TOKEN']
      );
    }

    if (!$extract) {
      self::logout();
      return false;
    }
    $guid = $extract['guid'];
    $expire = $extract['expire'];

    $user = Nymph::getEntity(
        ['class' => '\Tilmeld\Entities\User'],
        ['&',
          'guid' => $guid
        ]
    );
    if (!$user || !$user->guid || !$user->enabled) {
      self::logout();
      return false;
    }

    if ($expire < time() + self::$config['jwt_renew']) {
      // If the user is less than renew time from needing a new token, give them
      // a new one.
      self::login($user);
    } else {
      self::fillSession($user);
    }
    return true;
  }

  /**
   * Logs the given user into the system.
   *
   * @param \Tilmeld\Entities\User $user The user.
   * @return bool True on success, false on failure.
   */
  public static function login($user) {
    if (isset($user->guid) && $user->enabled) {
      $token = self::$config['jwt_builder']($user);
      $appUrlParts = parse_url(self::$config['app_url']);
      setcookie(
          'TILMELDAUTH',
          $token,
          time() + self::$config['jwt_expire'],
          $appUrlParts['path'],
          $appUrlParts['host'],
          $appUrlParts['scheme'] === 'https',
          false // Allow JS access (for CSRF protection).
      );
      self::fillSession($user);
      return true;
    }
    return false;
  }

  /**
   * Logs the current user out of the system.
   */
  public static function logout() {
    self::$currentUser = null;
    $appUrlParts = parse_url(self::$config['app_url']);
    setcookie(
        'TILMELDAUTH',
        '',
        null,
        $appUrlParts['path'],
        $appUrlParts['host']
    );
  }

  /**
   * Sort an array of groups hierarchically.
   *
   * An additional property of the groups can be used to sort them under their
   * parents.
   *
   * @param array &$array The array of groups.
   * @param string|null $property The name of the property to sort groups by.
   *                              Null for no additional sorting.
   * @param bool $caseSensitive Sort case sensitively.
   * @param bool $reverse Reverse the sort order.
   */
  public static function groupSort(
      &$array,
      $property = null,
      $caseSensitive = false,
      $reverse = false
  ) {
    Nymph::hsort($array, $property, 'parent', $caseSensitive, $reverse);
  }
}