Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Manage the access to the quiz.
  19   *
  20   * @package    quizaccess_seb
  21   * @author     Tim Hunt
  22   * @author     Luca Bösch <luca.boesch@bfh.ch>
  23   * @author     Andrew Madden <andrewmadden@catalyst-au.net>
  24   * @author     Dmitrii Metelkin <dmitriim@catalyst-au.net>
  25   * @copyright  2019 Catalyst IT
  26   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  27   */
  28  
  29  namespace quizaccess_seb;
  30  
  31  use context_module;
  32  use mod_quiz\quiz_settings;
  33  
  34  defined('MOODLE_INTERNAL') || die();
  35  
  36  /**
  37   * Manage the access to the quiz.
  38   *
  39   * @copyright  2020 Catalyst IT
  40   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  41   */
  42  class seb_access_manager {
  43  
  44      /** Header sent by Safe Exam Browser containing the Config Key hash. */
  45      private const CONFIG_KEY_HEADER = 'HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH';
  46  
  47      /** Header sent by Safe Exam Browser containing the Browser Exam Key hash. */
  48      private const BROWSER_EXAM_KEY_HEADER = 'HTTP_X_SAFEEXAMBROWSER_REQUESTHASH';
  49  
  50      /** @var quiz_settings $quiz A quiz object containing all information pertaining to current quiz. */
  51      private $quiz;
  52  
  53      /** @var seb_quiz_settings $quizsettings A quiz settings persistent object containing plugin settings */
  54      private $quizsettings;
  55  
  56      /** @var context_module $context Context of this quiz activity. */
  57      private $context;
  58  
  59      /** @var string|null $validconfigkey Expected valid SEB config key. */
  60      private $validconfigkey = null;
  61  
  62      /**
  63       * The access_manager constructor.
  64       *
  65       * @param quiz_settings $quiz The details of the quiz.
  66       */
  67      public function __construct(quiz_settings $quiz) {
  68          $this->quiz = $quiz;
  69          $this->context = context_module::instance($quiz->get_cmid());
  70          $this->quizsettings = seb_quiz_settings::get_by_quiz_id($quiz->get_quizid());
  71          $this->validconfigkey = seb_quiz_settings::get_config_key_by_quiz_id($quiz->get_quizid());
  72      }
  73  
  74      /**
  75       * Validate browser exam key. It will validate a provided browser exam key if provided, then will fall back to checking
  76       * the header.
  77       *
  78       * @param string|null $browserexamkey Optional. Can validate a provided key, or will fall back to checking header.
  79       * @param string|null $url Optionally provide URL of page to validate.
  80       * @return bool
  81       */
  82      public function validate_browser_exam_key(?string $browserexamkey = null, ?string $url = null): bool {
  83          if (!$this->should_validate_browser_exam_key()) {
  84              // Browser exam key should not be checked, so do not prevent access.
  85              return true;
  86          }
  87  
  88          if (!$this->is_allowed_browser_examkeys_configured()) {
  89              return true; // If no browser exam keys, no check required.
  90          }
  91  
  92          if (empty($browserexamkey)) {
  93              $browserexamkey = $this->get_received_browser_exam_key();
  94          }
  95  
  96          $validbrowserexamkeys = $this->quizsettings->get('allowedbrowserexamkeys');
  97  
  98          // If the Browser Exam Key header isn't present, prevent access.
  99          if (is_null($browserexamkey)) {
 100              return false;
 101          }
 102  
 103          return $this->check_browser_exam_keys($validbrowserexamkeys, $browserexamkey, $url);
 104      }
 105  
 106      /**
 107       * Validate a config key. It will check a provided config key if provided then will fall back to checking config
 108       * key in header.
 109       *
 110       * @param string|null $configkey Optional. Can validate a provided key, or will fall back to checking header.
 111       * @param string|null $url URL of page to validate.
 112       * @return bool
 113       */
 114      public function validate_config_key(?string $configkey = null, ?string $url = null): bool {
 115          if (!$this->should_validate_config_key()) {
 116              // Config key should not be checked, so do not prevent access.
 117              return true;
 118          }
 119  
 120          // If using client config, or with no requirement, then no check required.
 121          $requiredtype = $this->get_seb_use_type();
 122          if ($requiredtype == settings_provider::USE_SEB_NO
 123                  || $requiredtype == settings_provider::USE_SEB_CLIENT_CONFIG) {
 124              return true;
 125          }
 126  
 127          if (empty($configkey)) {
 128              $configkey = $this->get_received_config_key();
 129          }
 130  
 131          if (empty($this->validconfigkey)) {
 132              return false; // No config key has been saved.
 133          }
 134  
 135          if (is_null($configkey)) {
 136              return false;
 137          }
 138  
 139          // Check if there is a valid config key supplied in the header.
 140          return $this->check_key($this->validconfigkey, $configkey, $url);
 141      }
 142  
 143      /**
 144       * Check if Safe Exam Browser is required to access quiz.
 145       * If quizsettings do not exist, then there is no requirement for using SEB.
 146       *
 147       * @return bool If required.
 148       */
 149      public function seb_required() : bool {
 150          if (!$this->quizsettings) {
 151              return false;
 152          } else {
 153              return $this->get_seb_use_type() != settings_provider::USE_SEB_NO;
 154          }
 155      }
 156  
 157      /**
 158       * This is the basic check for the Safe Exam Browser previously used in the quizaccess_safebrowser plugin that
 159       * managed basic Moodle interactions with SEB.
 160       *
 161       * @return bool
 162       */
 163      public function validate_basic_header(): bool {
 164          if (!$this->should_validate_basic_header()) {
 165              // Config key should not be checked, so do not prevent access.
 166              return true;
 167          }
 168  
 169          if ($this->get_seb_use_type() == settings_provider::USE_SEB_CLIENT_CONFIG) {
 170              return $this->is_using_seb();
 171          }
 172          return true;
 173      }
 174  
 175      /**
 176       * Check if using Safe Exam Browser.
 177       *
 178       * @return bool
 179       */
 180      public function is_using_seb(): bool {
 181          if (isset($_SERVER['HTTP_USER_AGENT'])) {
 182              return strpos($_SERVER['HTTP_USER_AGENT'], 'SEB') !== false;
 183          }
 184  
 185          return false;
 186      }
 187  
 188      /**
 189       * Check if user has any capability to bypass the Safe Exam Browser requirement.
 190       *
 191       * @return bool True if user can bypass check.
 192       */
 193      public function can_bypass_seb(): bool {
 194          return has_capability('quizaccess/seb:bypassseb', $this->context);
 195      }
 196  
 197      /**
 198       * Return the full URL that was used to request the current page, which is
 199       * what we need for verifying the X-SafeExamBrowser-RequestHash header.
 200       */
 201      private function get_this_page_url(): string {
 202          global $CFG, $FULLME;
 203          // If $FULLME not set fall back to wwwroot.
 204          if ($FULLME == null) {
 205              return $CFG->wwwroot;
 206          }
 207          return $FULLME;
 208      }
 209  
 210      /**
 211       * Return expected SEB config key.
 212       *
 213       * @return string|null
 214       */
 215      public function get_valid_config_key(): ?string {
 216          return $this->validconfigkey;
 217      }
 218  
 219      /**
 220       * Getter for the quiz object.
 221       *
 222       * @return \mod_quiz\quiz_settings
 223       */
 224      public function get_quiz() : quiz_settings {
 225          return $this->quiz;
 226      }
 227  
 228      /**
 229       * Check that at least one browser exam key exists in the quiz settings.
 230       *
 231       * @return bool True if one or more keys are set in quiz settings.
 232       */
 233      private function is_allowed_browser_examkeys_configured(): bool {
 234          return !empty($this->quizsettings->get('allowedbrowserexamkeys'));
 235      }
 236  
 237      /**
 238       * Check the hash from the request header against the permitted browser exam keys.
 239       *
 240       * @param array $keys Allowed browser exam keys.
 241       * @param string $header The value of the X-SafeExamBrowser-RequestHash to check.
 242       * @param string|null $url URL of page to validate.
 243       * @return bool True if the hash matches.
 244       */
 245      private function check_browser_exam_keys(array $keys, string $header, ?string $url = null): bool {
 246          foreach ($keys as $key) {
 247              if ($this->check_key($key, $header, $url)) {
 248                  return true;
 249              }
 250          }
 251          return false;
 252      }
 253  
 254      /**
 255       * Check the hash from the request header against a single permitted key.
 256       *
 257       * @param string $validkey An allowed key.
 258       * @param string $key The value of X-SafeExamBrowser-RequestHash, X-SafeExamBrowser-ConfigKeyHash or a provided key to check.
 259       * @param string|null $url URL of page to validate.
 260       * @return bool True if the hash matches.
 261       */
 262      private function check_key(string $validkey, string $key, ?string $url = null): bool {
 263          if (empty($url)) {
 264              $url = $this->get_this_page_url();
 265          }
 266          return hash('sha256', $url . $validkey) === $key;
 267      }
 268  
 269      /**
 270       * Returns Safe Exam Browser Config Key hash.
 271       *
 272       * @return string|null
 273       */
 274      public function get_received_config_key(): ?string {
 275          if (isset($_SERVER[self::CONFIG_KEY_HEADER])) {
 276              return trim($_SERVER[self::CONFIG_KEY_HEADER]);
 277          }
 278  
 279          return null;
 280      }
 281  
 282      /**
 283       * Returns the Browser Exam Key hash.
 284       *
 285       * @return string|null
 286       */
 287      public function get_received_browser_exam_key(): ?string {
 288          if (isset($_SERVER[self::BROWSER_EXAM_KEY_HEADER])) {
 289              return trim($_SERVER[self::BROWSER_EXAM_KEY_HEADER]);
 290          }
 291  
 292          return null;
 293      }
 294  
 295      /**
 296       * Get type of SEB usage for the quiz.
 297       *
 298       * @return int
 299       */
 300      public function get_seb_use_type(): int {
 301          if (empty($this->quizsettings)) {
 302              return settings_provider::USE_SEB_NO;
 303          } else {
 304              return $this->quizsettings->get('requiresafeexambrowser');
 305          }
 306      }
 307  
 308      /**
 309       * Should validate basic header?
 310       *
 311       * @return bool
 312       */
 313      public function should_validate_basic_header(): bool {
 314          return in_array($this->get_seb_use_type(), [
 315              settings_provider::USE_SEB_CLIENT_CONFIG,
 316          ]);
 317      }
 318  
 319      /**
 320       * Should validate SEB config key?
 321       * @return bool
 322       */
 323      public function should_validate_config_key(): bool {
 324          return in_array($this->get_seb_use_type(), [
 325              settings_provider::USE_SEB_CONFIG_MANUALLY,
 326              settings_provider::USE_SEB_TEMPLATE,
 327              settings_provider::USE_SEB_UPLOAD_CONFIG,
 328          ]);
 329      }
 330  
 331      /**
 332       * Should validate browser exam key?
 333       *
 334       * @return bool
 335       */
 336      public function should_validate_browser_exam_key(): bool {
 337          return in_array($this->get_seb_use_type(), [
 338              settings_provider::USE_SEB_UPLOAD_CONFIG,
 339              settings_provider::USE_SEB_CLIENT_CONFIG,
 340          ]);
 341      }
 342  
 343      /**
 344       * Set session access for quiz.
 345       *
 346       * @param bool $accessallowed
 347       */
 348      public function set_session_access(bool $accessallowed): void {
 349          global $SESSION;
 350          if (!isset($SESSION->quizaccess_seb_access)) {
 351              $SESSION->quizaccess_seb_access = [];
 352          }
 353          $SESSION->quizaccess_seb_access[$this->quiz->get_cmid()] = $accessallowed;
 354      }
 355  
 356      /**
 357       * Check session access for quiz if already set.
 358       *
 359       * @return bool
 360       */
 361      public function validate_session_access(): bool {
 362          global $SESSION;
 363          return !empty($SESSION->quizaccess_seb_access[$this->quiz->get_cmid()]);
 364      }
 365  
 366      /**
 367       * Unset the global session access variable for this quiz.
 368       */
 369      public function clear_session_access(): void {
 370          global $SESSION;
 371          unset($SESSION->quizaccess_seb_access[$this->quiz->get_cmid()]);
 372      }
 373  
 374      /**
 375       * Redirect to SEB config link. This will force Safe Exam Browser to be reconfigured.
 376       */
 377      public function redirect_to_seb_config_link(): void {
 378          global $PAGE;
 379  
 380          $seblink = \quizaccess_seb\link_generator::get_link($this->quiz->get_cmid(), true, is_https());
 381          $PAGE->requires->js_amd_inline("document.location.replace('" . $seblink . "')");
 382      }
 383  
 384      /**
 385       * Check if we need to redirect to SEB config link.
 386       *
 387       * @return bool
 388       */
 389      public function should_redirect_to_seb_config_link(): bool {
 390          // We check if there is an existing config key header. If there is none, we assume that
 391          // the SEB application is not using header verification so auto redirect should not proceed.
 392          $haskeyinheader = !is_null($this->get_received_config_key());
 393  
 394          return $this->is_using_seb()
 395                  && get_config('quizaccess_seb', 'autoreconfigureseb')
 396                  && $haskeyinheader;
 397      }
 398  }