From 3a60aec87d821c01e3530474b0e35f731f0edb1e Mon Sep 17 00:00:00 2001 From: Andrew Madden Date: Tue, 29 Mar 2022 08:17:13 +1100 Subject: [PATCH] MDL-75635 quizaccess_seb: Implement Safe Exam Browser JS API This is a backport of MDL-72188 * Replaces checking header for SEB config keys to assess quiz access. * Adds new web service accessible via Ajax * Forces the use of the new API where available * Stores access in Moodle SESSION for quiz instead of checking every page. --- .../seb/amd/build/validate_quiz_access.min.js | 11 + .../amd/build/validate_quiz_access.min.js.map | 1 + mod/quiz/accessrule/seb/amd/build/view.min.js | 11 + .../accessrule/seb/amd/build/view.min.js.map | 1 + .../seb/amd/src/validate_quiz_access.js | 121 ++++++++ mod/quiz/accessrule/seb/amd/src/view.js | 82 ++++++ .../accessrule/seb/classes/access_manager.php | 166 ++++++++--- .../seb/classes/event/access_prevented.php | 12 +- .../classes/external/validate_quiz_keys.php | 155 +++++++++++ .../accessrule/seb/classes/quiz_settings.php | 5 + mod/quiz/accessrule/seb/db/services.php | 36 +++ .../accessrule/seb/lang/en/quizaccess_seb.php | 4 + mod/quiz/accessrule/seb/rule.php | 49 ++-- .../accessrule/seb/templates/loading.mustache | 34 +++ .../seb/tests/access_manager_test.php | 170 +++++++++++- .../seb/tests/event/events_test.php | 52 ++++ .../external/validate_quiz_access_test.php | 260 ++++++++++++++++++ mod/quiz/accessrule/seb/tests/helper_test.php | 3 +- .../seb/tests/quiz_settings_test.php | 47 ++-- mod/quiz/accessrule/seb/tests/rule_test.php | 68 +++-- .../seb/tests/test_helper_trait.php | 6 +- mod/quiz/accessrule/seb/version.php | 2 +- 22 files changed, 1176 insertions(+), 120 deletions(-) create mode 100644 mod/quiz/accessrule/seb/amd/build/validate_quiz_access.min.js create mode 100644 mod/quiz/accessrule/seb/amd/build/validate_quiz_access.min.js.map create mode 100644 mod/quiz/accessrule/seb/amd/build/view.min.js create mode 100644 mod/quiz/accessrule/seb/amd/build/view.min.js.map create mode 100644 mod/quiz/accessrule/seb/amd/src/validate_quiz_access.js create mode 100644 mod/quiz/accessrule/seb/amd/src/view.js create mode 100644 mod/quiz/accessrule/seb/classes/external/validate_quiz_keys.php create mode 100644 mod/quiz/accessrule/seb/db/services.php create mode 100644 mod/quiz/accessrule/seb/templates/loading.mustache create mode 100644 mod/quiz/accessrule/seb/tests/external/validate_quiz_access_test.php diff --git a/mod/quiz/accessrule/seb/amd/build/validate_quiz_access.min.js b/mod/quiz/accessrule/seb/amd/build/validate_quiz_access.min.js new file mode 100644 index 00000000000..1493889ea1c --- /dev/null +++ b/mod/quiz/accessrule/seb/amd/build/validate_quiz_access.min.js @@ -0,0 +1,11 @@ +define("quizaccess_seb/validate_quiz_access",["exports","core/ajax","core/config","core/notification","quizaccess_seb/view"],(function(_exports,_ajax,_config,_notification,View){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +/** + * Validate Safe Exam Browser access keys. + * + * @module quizaccess_seb/validate_quiz_access + * @author Andrew Madden + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_ajax=_interopRequireDefault(_ajax),_config=_interopRequireDefault(_config),_notification=_interopRequireDefault(_notification),View=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(View),window.SafeExamBrowser=window.SafeExamBrowser||null;const safeExamBrowserKeysUpdated=function(cmid){let autoreconfigure=arguments.length>1&&void 0!==arguments[1]&&arguments[1];isQuizAccessValid(cmid).then((response=>(setTimeout(View.clearLoadingAlert,1e3),response.configkey&&response.browserexamkey?View.allowAccess():(!0===autoreconfigure&&!1===response.configkey&&reconfigureSafeExamBrowser(cmid),setTimeout(View.showValidationFailedModal,1e3)),response))).catch((err=>{_notification.default.exception(err)}))},isQuizAccessValid=cmid=>{const request={methodname:"quizaccess_seb_validate_quiz_keys",args:{cmid:cmid,url:window.location.href,configkey:window.SafeExamBrowser.security.configKey,browserexamkey:window.SafeExamBrowser.security.browserExamKey}};return _ajax.default.call([request])[0]},isKeyEmpty=key=>":"===key,reconfigureSafeExamBrowser=cmid=>{const redirecturl=_config.default.wwwroot.replace(/^http/i,"seb")+"/mod/quiz/accessrule/seb/config.php?cmid="+cmid;document.location.replace(redirecturl)};_exports.init=async function(cmid){let autoreconfigure=arguments.length>1&&void 0!==arguments[1]&&arguments[1];null!==window.SafeExamBrowser&&(await View.addLoadingAlert(),isKeyEmpty(window.SafeExamBrowser.security.configKey)&&isKeyEmpty(window.SafeExamBrowser.security.browserExamKey)?window.SafeExamBrowser.security.updateKeys(safeExamBrowserKeysUpdated):safeExamBrowserKeysUpdated(cmid,autoreconfigure))}})); + +//# sourceMappingURL=validate_quiz_access.min.js.map \ No newline at end of file diff --git a/mod/quiz/accessrule/seb/amd/build/validate_quiz_access.min.js.map b/mod/quiz/accessrule/seb/amd/build/validate_quiz_access.min.js.map new file mode 100644 index 00000000000..70cbb758d90 --- /dev/null +++ b/mod/quiz/accessrule/seb/amd/build/validate_quiz_access.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"validate_quiz_access.min.js","sources":["../src/validate_quiz_access.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Validate Safe Exam Browser access keys.\n *\n * @module quizaccess_seb/validate_quiz_access\n * @author Andrew Madden \n * @copyright 2021 Catalyst IT\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\nimport Config from 'core/config';\nimport Notification from \"core/notification\";\nimport * as View from 'quizaccess_seb/view';\n\n// SafeExamBrowser object will be automatically initialized if using the SafeExamBrowser application.\nwindow.SafeExamBrowser = window.SafeExamBrowser || null;\n\n/**\n * Once the keys are fetched, action checking access.\n *\n * @param {init} cmid Value of course module id of the quiz.\n * @param {boolean} autoreconfigure Value of Moodle setting: quizaccess_seb/autoreconfigureseb.\n */\nconst safeExamBrowserKeysUpdated = (cmid, autoreconfigure = false) => {\n // Action opening up the quiz.\n isQuizAccessValid(cmid).then((response) => {\n // Show the alert for an extra second to allow user to see it.\n setTimeout(View.clearLoadingAlert, 1000);\n\n if (response.configkey && response.browserexamkey) {\n View.allowAccess();\n } else {\n // If autoreconfigureseb is enabled, attempt to reconfigure page with quiz settings.\n if (autoreconfigure === true && response.configkey === false) {\n reconfigureSafeExamBrowser(cmid);\n }\n setTimeout(View.showValidationFailedModal, 1000);\n }\n\n return response;\n }).catch(err => {\n Notification.exception(err);\n });\n};\n\n/**\n * Validate keys in Moodle backend.\n *\n * @param {init} cmid Value of course module id of the quiz.\n * @return {Promise}\n */\nconst isQuizAccessValid = (cmid) => {\n const request = {\n methodname: 'quizaccess_seb_validate_quiz_keys',\n args: {\n cmid: cmid,\n url: window.location.href,\n configkey: window.SafeExamBrowser.security.configKey,\n browserexamkey: window.SafeExamBrowser.security.browserExamKey\n },\n };\n\n return Ajax.call([request])[0];\n};\n\n/**\n * Check if the key is not yet set.\n *\n * @param {string} key config key or browser exam key.\n * @return {boolean}\n */\nconst isKeyEmpty = (key) => {\n // If the SafeExamBrowser object is defined, the default 'empty' value of the configKey and browserExamKey is ':'.\n return key === \":\";\n};\n\n/**\n * Reload Safe Exam Browser with current quiz configuration.\n *\n * @param {init} cmid Value of course module id of the quiz.\n */\nconst reconfigureSafeExamBrowser = (cmid) => {\n const domain = Config.wwwroot.replace(/^http/i, 'seb');\n const redirecturl = domain + '/mod/quiz/accessrule/seb/config.php?cmid=' + cmid;\n document.location.replace(redirecturl);\n};\n\n/**\n * Initialize the process of fetching the keys.\n *\n * @param {init} cmid Value of course module id of the quiz.\n * @param {boolean} autoreconfigure Value of Moodle setting: quizaccess_seb/autoreconfigureseb.\n */\nexport const init = async(cmid, autoreconfigure = false) => {\n // If the SafeExamBrowser object is instantiated, try and use it to fetch the access keys.\n if (window.SafeExamBrowser !== null) {\n await View.addLoadingAlert();\n // If the SEB keys are already set, we can call our callback directly.\n\n if (!isKeyEmpty(window.SafeExamBrowser.security.configKey) || !isKeyEmpty(window.SafeExamBrowser.security.browserExamKey)) {\n safeExamBrowserKeysUpdated(cmid, autoreconfigure);\n } else {\n window.SafeExamBrowser.security.updateKeys(safeExamBrowserKeysUpdated);\n }\n }\n};\n"],"names":["window","SafeExamBrowser","safeExamBrowserKeysUpdated","cmid","autoreconfigure","isQuizAccessValid","then","response","setTimeout","View","clearLoadingAlert","configkey","browserexamkey","allowAccess","reconfigureSafeExamBrowser","showValidationFailedModal","catch","err","exception","request","methodname","args","url","location","href","security","configKey","browserExamKey","Ajax","call","isKeyEmpty","key","redirecturl","Config","wwwroot","replace","document","async","addLoadingAlert","updateKeys"],"mappings":";;;;;;;;02BA8BAA,OAAOC,gBAAkBD,OAAOC,iBAAmB,WAQ7CC,2BAA6B,SAACC,UAAMC,wEAEtCC,kBAAkBF,MAAMG,MAAMC,WAE1BC,WAAWC,KAAKC,kBAAmB,KAE/BH,SAASI,WAAaJ,SAASK,eAC/BH,KAAKI,gBAGmB,IAApBT,kBAAmD,IAAvBG,SAASI,WACrCG,2BAA2BX,MAE/BK,WAAWC,KAAKM,0BAA2B,MAGxCR,YACRS,OAAMC,4BACQC,UAAUD,SAUzBZ,kBAAqBF,aACjBgB,QAAU,CACZC,WAAY,oCACZC,KAAM,CACFlB,KAAMA,KACNmB,IAAKtB,OAAOuB,SAASC,KACrBb,UAAWX,OAAOC,gBAAgBwB,SAASC,UAC3Cd,eAAgBZ,OAAOC,gBAAgBwB,SAASE,wBAIjDC,cAAKC,KAAK,CAACV,UAAU,IAS1BW,WAAcC,KAED,MAARA,IAQLjB,2BAA8BX,aAE1B6B,YADSC,gBAAOC,QAAQC,QAAQ,SAAU,OACnB,4CAA8ChC,KAC3EiC,SAASb,SAASY,QAAQH,4BASVK,eAAMlC,UAAMC,wEAEG,OAA3BJ,OAAOC,wBACDQ,KAAK6B,kBAGNR,WAAW9B,OAAOC,gBAAgBwB,SAASC,YAAeI,WAAW9B,OAAOC,gBAAgBwB,SAASE,gBAGtG3B,OAAOC,gBAAgBwB,SAASc,WAAWrC,4BAF3CA,2BAA2BC,KAAMC"} \ No newline at end of file diff --git a/mod/quiz/accessrule/seb/amd/build/view.min.js b/mod/quiz/accessrule/seb/amd/build/view.min.js new file mode 100644 index 00000000000..725150f612e --- /dev/null +++ b/mod/quiz/accessrule/seb/amd/build/view.min.js @@ -0,0 +1,11 @@ +define("quizaccess_seb/view",["exports","core/notification","core/templates","core/str","core/modal_factory"],(function(_exports,_notification,Templates,Str,ModalFactory){var obj; +/** + * Manage the quiz views. + * + * @module quizaccess_seb/view + * @author Andrew Madden + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.showValidationFailedModal=_exports.clearLoadingAlert=_exports.allowAccess=_exports.addLoadingAlert=void 0,_notification=(obj=_notification)&&obj.__esModule?obj:{default:obj},Templates=_interopRequireWildcard(Templates),Str=_interopRequireWildcard(Str),ModalFactory=_interopRequireWildcard(ModalFactory);const SELECTOR_MAIN="#region-main",SELECTOR_LOADING=".seb-loading",TEMPLATE_LOADING="quizaccess_seb/loading";_exports.allowAccess=()=>{window.location.reload()};_exports.addLoadingAlert=()=>Templates.render(TEMPLATE_LOADING,{}).then(((html,js)=>{const alertRegion=window.document.querySelector(SELECTOR_MAIN);return Templates.prependNodeContents(alertRegion,html,js)})).catch(_notification.default.exception);_exports.clearLoadingAlert=()=>{const alert=window.document.querySelector(SELECTOR_LOADING);alert&&Templates.replaceNode(alert,"","")};_exports.showValidationFailedModal=()=>{ModalFactory.create({type:ModalFactory.types.ALERT,title:Str.get_string("sebkeysvalidationfailed","quizaccess_seb"),body:Str.get_string("invalidkeys","quizaccess_seb"),large:!1}).then((modal=>{modal.show()})).fail(_notification.default.exception)}})); + +//# sourceMappingURL=view.min.js.map \ No newline at end of file diff --git a/mod/quiz/accessrule/seb/amd/build/view.min.js.map b/mod/quiz/accessrule/seb/amd/build/view.min.js.map new file mode 100644 index 00000000000..a3f1510252a --- /dev/null +++ b/mod/quiz/accessrule/seb/amd/build/view.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"view.min.js","sources":["../src/view.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Manage the quiz views.\n *\n * @module quizaccess_seb/view\n * @author Andrew Madden \n * @copyright 2021 Catalyst IT\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Notification from \"core/notification\";\nimport * as Templates from \"core/templates\";\nimport * as Str from \"core/str\";\nimport * as ModalFactory from \"core/modal_factory\";\n\n/** @var SELECTOR List of CSS selectors. */\nconst SELECTOR = {\n MAIN: '#region-main',\n LOADING: '.seb-loading',\n};\n\n/** @var Template List of mustache templates. */\nconst TEMPLATE = {\n LOADING: 'quizaccess_seb/loading',\n};\n\n/**\n * Manages view when access has been granted.\n */\nexport const allowAccess = () => {\n window.location.reload();\n};\n\n/**\n * Add an alert to page to inform that Safe Exam Browser access is being checked.\n *\n * @return {Promise}\n */\nexport const addLoadingAlert = () => {\n return Templates.render(TEMPLATE.LOADING, {}).then((html, js) => {\n const alertRegion = window.document.querySelector(SELECTOR.MAIN);\n return Templates.prependNodeContents(alertRegion, html, js);\n }).catch(Notification.exception);\n};\n\n/**\n * Remove the Safe Exam Browser access check alert from the page.\n */\nexport const clearLoadingAlert = () => {\n const alert = window.document.querySelector(SELECTOR.LOADING);\n if (alert) {\n Templates.replaceNode(alert, '', '');\n }\n};\n\n/**\n * Display validation failed modal.\n */\nexport const showValidationFailedModal = () => {\n ModalFactory.create({\n type: ModalFactory.types.ALERT,\n title: Str.get_string('sebkeysvalidationfailed', 'quizaccess_seb'),\n body: Str.get_string('invalidkeys', 'quizaccess_seb'),\n large: false,\n }).then((modal) => {\n modal.show();\n }).fail(Notification.exception);\n};\n"],"names":["SELECTOR","TEMPLATE","window","location","reload","Templates","render","then","html","js","alertRegion","document","querySelector","prependNodeContents","catch","Notification","exception","alert","replaceNode","ModalFactory","create","type","types","ALERT","title","Str","get_string","body","large","modal","show","fail"],"mappings":";;;;;;;;kzCA8BMA,cACI,eADJA,iBAEO,eAIPC,iBACO,8CAMc,KACvBC,OAAOC,SAASC,mCAQW,IACpBC,UAAUC,OAAOL,iBAAkB,IAAIM,MAAK,CAACC,KAAMC,YAChDC,YAAcR,OAAOS,SAASC,cAAcZ,sBAC3CK,UAAUQ,oBAAoBH,YAAaF,KAAMC,OACzDK,MAAMC,sBAAaC,sCAMO,WACvBC,MAAQf,OAAOS,SAASC,cAAcZ,kBACxCiB,OACAZ,UAAUa,YAAYD,MAAO,GAAI,wCAOA,KACrCE,aAAaC,OAAO,CAChBC,KAAMF,aAAaG,MAAMC,MACzBC,MAAOC,IAAIC,WAAW,0BAA2B,kBACjDC,KAAMF,IAAIC,WAAW,cAAe,kBACpCE,OAAO,IACRrB,MAAMsB,QACLA,MAAMC,UACPC,KAAKhB,sBAAaC"} \ No newline at end of file diff --git a/mod/quiz/accessrule/seb/amd/src/validate_quiz_access.js b/mod/quiz/accessrule/seb/amd/src/validate_quiz_access.js new file mode 100644 index 00000000000..bfe2abb4592 --- /dev/null +++ b/mod/quiz/accessrule/seb/amd/src/validate_quiz_access.js @@ -0,0 +1,121 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Validate Safe Exam Browser access keys. + * + * @module quizaccess_seb/validate_quiz_access + * @author Andrew Madden + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Ajax from 'core/ajax'; +import Config from 'core/config'; +import Notification from "core/notification"; +import * as View from 'quizaccess_seb/view'; + +// SafeExamBrowser object will be automatically initialized if using the SafeExamBrowser application. +window.SafeExamBrowser = window.SafeExamBrowser || null; + +/** + * Once the keys are fetched, action checking access. + * + * @param {init} cmid Value of course module id of the quiz. + * @param {boolean} autoreconfigure Value of Moodle setting: quizaccess_seb/autoreconfigureseb. + */ +const safeExamBrowserKeysUpdated = (cmid, autoreconfigure = false) => { + // Action opening up the quiz. + isQuizAccessValid(cmid).then((response) => { + // Show the alert for an extra second to allow user to see it. + setTimeout(View.clearLoadingAlert, 1000); + + if (response.configkey && response.browserexamkey) { + View.allowAccess(); + } else { + // If autoreconfigureseb is enabled, attempt to reconfigure page with quiz settings. + if (autoreconfigure === true && response.configkey === false) { + reconfigureSafeExamBrowser(cmid); + } + setTimeout(View.showValidationFailedModal, 1000); + } + + return response; + }).catch(err => { + Notification.exception(err); + }); +}; + +/** + * Validate keys in Moodle backend. + * + * @param {init} cmid Value of course module id of the quiz. + * @return {Promise} + */ +const isQuizAccessValid = (cmid) => { + const request = { + methodname: 'quizaccess_seb_validate_quiz_keys', + args: { + cmid: cmid, + url: window.location.href, + configkey: window.SafeExamBrowser.security.configKey, + browserexamkey: window.SafeExamBrowser.security.browserExamKey + }, + }; + + return Ajax.call([request])[0]; +}; + +/** + * Check if the key is not yet set. + * + * @param {string} key config key or browser exam key. + * @return {boolean} + */ +const isKeyEmpty = (key) => { + // If the SafeExamBrowser object is defined, the default 'empty' value of the configKey and browserExamKey is ':'. + return key === ":"; +}; + +/** + * Reload Safe Exam Browser with current quiz configuration. + * + * @param {init} cmid Value of course module id of the quiz. + */ +const reconfigureSafeExamBrowser = (cmid) => { + const domain = Config.wwwroot.replace(/^http/i, 'seb'); + const redirecturl = domain + '/mod/quiz/accessrule/seb/config.php?cmid=' + cmid; + document.location.replace(redirecturl); +}; + +/** + * Initialize the process of fetching the keys. + * + * @param {init} cmid Value of course module id of the quiz. + * @param {boolean} autoreconfigure Value of Moodle setting: quizaccess_seb/autoreconfigureseb. + */ +export const init = async(cmid, autoreconfigure = false) => { + // If the SafeExamBrowser object is instantiated, try and use it to fetch the access keys. + if (window.SafeExamBrowser !== null) { + await View.addLoadingAlert(); + // If the SEB keys are already set, we can call our callback directly. + + if (!isKeyEmpty(window.SafeExamBrowser.security.configKey) || !isKeyEmpty(window.SafeExamBrowser.security.browserExamKey)) { + safeExamBrowserKeysUpdated(cmid, autoreconfigure); + } else { + window.SafeExamBrowser.security.updateKeys(safeExamBrowserKeysUpdated); + } + } +}; diff --git a/mod/quiz/accessrule/seb/amd/src/view.js b/mod/quiz/accessrule/seb/amd/src/view.js new file mode 100644 index 00000000000..20d84826b99 --- /dev/null +++ b/mod/quiz/accessrule/seb/amd/src/view.js @@ -0,0 +1,82 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Manage the quiz views. + * + * @module quizaccess_seb/view + * @author Andrew Madden + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Notification from "core/notification"; +import * as Templates from "core/templates"; +import * as Str from "core/str"; +import * as ModalFactory from "core/modal_factory"; + +/** @var SELECTOR List of CSS selectors. */ +const SELECTOR = { + MAIN: '#region-main', + LOADING: '.seb-loading', +}; + +/** @var Template List of mustache templates. */ +const TEMPLATE = { + LOADING: 'quizaccess_seb/loading', +}; + +/** + * Manages view when access has been granted. + */ +export const allowAccess = () => { + window.location.reload(); +}; + +/** + * Add an alert to page to inform that Safe Exam Browser access is being checked. + * + * @return {Promise} + */ +export const addLoadingAlert = () => { + return Templates.render(TEMPLATE.LOADING, {}).then((html, js) => { + const alertRegion = window.document.querySelector(SELECTOR.MAIN); + return Templates.prependNodeContents(alertRegion, html, js); + }).catch(Notification.exception); +}; + +/** + * Remove the Safe Exam Browser access check alert from the page. + */ +export const clearLoadingAlert = () => { + const alert = window.document.querySelector(SELECTOR.LOADING); + if (alert) { + Templates.replaceNode(alert, '', ''); + } +}; + +/** + * Display validation failed modal. + */ +export const showValidationFailedModal = () => { + ModalFactory.create({ + type: ModalFactory.types.ALERT, + title: Str.get_string('sebkeysvalidationfailed', 'quizaccess_seb'), + body: Str.get_string('invalidkeys', 'quizaccess_seb'), + large: false, + }).then((modal) => { + modal.show(); + }).fail(Notification.exception); +}; diff --git a/mod/quiz/accessrule/seb/classes/access_manager.php b/mod/quiz/accessrule/seb/classes/access_manager.php index 6a6bfc948b2..58f47b10205 100644 --- a/mod/quiz/accessrule/seb/classes/access_manager.php +++ b/mod/quiz/accessrule/seb/classes/access_manager.php @@ -56,8 +56,7 @@ class access_manager { /** @var context_module $context Context of this quiz activity. */ private $context; - /** @var string|null $validconfigkey Expected valid SEB config key. - */ + /** @var string|null $validconfigkey Expected valid SEB config key. */ private $validconfigkey = null; /** @@ -73,31 +72,51 @@ class access_manager { } /** - * Check if the browser exam key hash in header matches one of the listed browser exam keys from quiz settings. + * Validate browser exam key. It will validate a provided browser exam key if provided, then will fall back to checking + * the header. * - * @return bool True if header key matches one of the saved keys. + * @param string|null $browserexamkey Optional. Can validate a provided key, or will fall back to checking header. + * @param string|null $url Optionally provide URL of page to validate. + * @return bool */ - public function validate_browser_exam_keys() : bool { - // If browser exam keys are entered in settings, check they match the header. - $browserexamkeys = $this->quizsettings->get('allowedbrowserexamkeys'); - if (empty($browserexamkeys)) { + public function validate_browser_exam_key(?string $browserexamkey = null, ?string $url = null): bool { + if (!$this->should_validate_browser_exam_key()) { + // Browser exam key should not be checked, so do not prevent access. + return true; + } + + if (!$this->is_allowed_browser_examkeys_configured()) { return true; // If no browser exam keys, no check required. } + if (empty($browserexamkey)) { + $browserexamkey = $this->get_received_browser_exam_key(); + } + + $validbrowserexamkeys = $this->quizsettings->get('allowedbrowserexamkeys'); + // If the Browser Exam Key header isn't present, prevent access. - if (is_null($this->get_received_browser_exam_key())) { + if (is_null($browserexamkey)) { return false; } - return $this->check_browser_exam_keys($browserexamkeys, $this->get_received_browser_exam_key()); + return $this->check_browser_exam_keys($validbrowserexamkeys, $browserexamkey, $url); } /** - * Check if the config key hash in header matches quiz settings. + * Validate a config key. It will check a provided config key if provided then will fall back to checking config + * key in header. * - * @return bool True if header key matches saved key. + * @param string|null $configkey Optional. Can validate a provided key, or will fall back to checking header. + * @param string|null $url URL of page to validate. + * @return bool */ - public function validate_config_key() : bool { + public function validate_config_key(?string $configkey = null, ?string $url = null): bool { + if (!$this->should_validate_config_key()) { + // Config key should not be checked, so do not prevent access. + return true; + } + // If using client config, or with no requirement, then no check required. $requiredtype = $this->get_seb_use_type(); if ($requiredtype == settings_provider::USE_SEB_NO @@ -105,16 +124,20 @@ class access_manager { return true; } + if (empty($configkey)) { + $configkey = $this->get_received_config_key(); + } + if (empty($this->validconfigkey)) { return false; // No config key has been saved. } - // If the Config Key header isn't present, prevent access. - if (is_null($this->get_received_config_key())) { + if (is_null($configkey)) { return false; } - return $this->check_key($this->validconfigkey, $this->get_received_config_key()); + // Check if there is a valid config key supplied in the header. + return $this->check_key($this->validconfigkey, $configkey, $url); } /** @@ -137,7 +160,12 @@ class access_manager { * * @return bool */ - public function validate_basic_header() : bool { + public function validate_basic_header(): bool { + if (!$this->should_validate_basic_header()) { + // Config key should not be checked, so do not prevent access. + return true; + } + if ($this->get_seb_use_type() == settings_provider::USE_SEB_CLIENT_CONFIG) { return $this->is_using_seb(); } @@ -149,7 +177,7 @@ class access_manager { * * @return bool */ - public function is_using_seb() : bool { + public function is_using_seb(): bool { if (isset($_SERVER['HTTP_USER_AGENT'])) { return strpos($_SERVER['HTTP_USER_AGENT'], 'SEB') !== false; } @@ -162,7 +190,7 @@ class access_manager { * * @return bool True if user can bypass check. */ - public function can_bypass_seb() : bool { + public function can_bypass_seb(): bool { return has_capability('quizaccess/seb:bypassseb', $this->context); } @@ -170,7 +198,7 @@ class access_manager { * Return the full URL that was used to request the current page, which is * what we need for verifying the X-SafeExamBrowser-RequestHash header. */ - private function get_this_page_url() : string { + private function get_this_page_url(): string { global $CFG, $FULLME; // If $FULLME not set fall back to wwwroot. if ($FULLME == null) { @@ -184,7 +212,7 @@ class access_manager { * * @return string|null */ - public function get_valid_config_key() : ?string { + public function get_valid_config_key(): ?string { return $this->validconfigkey; } @@ -197,16 +225,26 @@ class access_manager { return $this->quiz; } + /** + * Check that at least one browser exam key exists in the quiz settings. + * + * @return bool True if one or more keys are set in quiz settings. + */ + private function is_allowed_browser_examkeys_configured(): bool { + return !empty($this->quizsettings->get('allowedbrowserexamkeys')); + } + /** * Check the hash from the request header against the permitted browser exam keys. * * @param array $keys Allowed browser exam keys. * @param string $header The value of the X-SafeExamBrowser-RequestHash to check. + * @param string|null $url URL of page to validate. * @return bool True if the hash matches. */ - private function check_browser_exam_keys(array $keys, string $header) : bool { + private function check_browser_exam_keys(array $keys, string $header, ?string $url = null): bool { foreach ($keys as $key) { - if ($this->check_key($key, $header)) { + if ($this->check_key($key, $header, $url)) { return true; } } @@ -216,12 +254,16 @@ class access_manager { /** * Check the hash from the request header against a single permitted key. * - * @param string $key an allowed key. - * @param string $header the value of the X-SafeExamBrowser-RequestHash or X-SafeExamBrowser-ConfigKeyHash to check. - * @return bool true if the hash matches. + * @param string $validkey An allowed key. + * @param string $key The value of X-SafeExamBrowser-RequestHash, X-SafeExamBrowser-ConfigKeyHash or a provided key to check. + * @param string|null $url URL of page to validate. + * @return bool True if the hash matches. */ - private function check_key($key, $header) : bool { - return hash('sha256', $this->get_this_page_url() . $key) === $header; + private function check_key(string $validkey, string $key, ?string $url = null): bool { + if (empty($url)) { + $url = $this->get_this_page_url(); + } + return hash('sha256', $url . $validkey) === $key; } /** @@ -229,7 +271,7 @@ class access_manager { * * @return string|null */ - public function get_received_config_key() { + public function get_received_config_key(): ?string { if (isset($_SERVER[self::CONFIG_KEY_HEADER])) { return trim($_SERVER[self::CONFIG_KEY_HEADER]); } @@ -242,7 +284,7 @@ class access_manager { * * @return string|null */ - public function get_received_browser_exam_key() { + public function get_received_browser_exam_key(): ?string { if (isset($_SERVER[self::BROWSER_EXAM_KEY_HEADER])) { return trim($_SERVER[self::BROWSER_EXAM_KEY_HEADER]); } @@ -255,7 +297,7 @@ class access_manager { * * @return int */ - public function get_seb_use_type() : int { + public function get_seb_use_type(): int { if (empty($this->quizsettings)) { return settings_provider::USE_SEB_NO; } else { @@ -268,7 +310,7 @@ class access_manager { * * @return bool */ - public function should_validate_basic_header() : bool { + public function should_validate_basic_header(): bool { return in_array($this->get_seb_use_type(), [ settings_provider::USE_SEB_CLIENT_CONFIG, ]); @@ -278,7 +320,7 @@ class access_manager { * Should validate SEB config key? * @return bool */ - public function should_validate_config_key() : bool { + public function should_validate_config_key(): bool { return in_array($this->get_seb_use_type(), [ settings_provider::USE_SEB_CONFIG_MANUALLY, settings_provider::USE_SEB_TEMPLATE, @@ -291,10 +333,66 @@ class access_manager { * * @return bool */ - public function should_validate_browser_exam_key() : bool { + public function should_validate_browser_exam_key(): bool { return in_array($this->get_seb_use_type(), [ settings_provider::USE_SEB_UPLOAD_CONFIG, settings_provider::USE_SEB_CLIENT_CONFIG, ]); } + + /** + * Set session access for quiz. + * + * @param bool $accessallowed + */ + public function set_session_access(bool $accessallowed): void { + global $SESSION; + if (!isset($SESSION->quizaccess_seb_access)) { + $SESSION->quizaccess_seb_access = []; + } + $SESSION->quizaccess_seb_access[$this->quiz->get_cmid()] = $accessallowed; + } + + /** + * Check session access for quiz if already set. + * + * @return bool + */ + public function validate_session_access(): bool { + global $SESSION; + return !empty($SESSION->quizaccess_seb_access[$this->quiz->get_cmid()]); + } + + /** + * Unset the global session access variable for this quiz. + */ + public function clear_session_access(): void { + global $SESSION; + unset($SESSION->quizaccess_seb_access[$this->quiz->get_cmid()]); + } + + /** + * Redirect to SEB config link. This will force Safe Exam Browser to be reconfigured. + */ + public function redirect_to_seb_config_link(): void { + global $PAGE; + + $seblink = \quizaccess_seb\link_generator::get_link($this->quiz->get_cmid(), true, is_https()); + $PAGE->requires->js_amd_inline("document.location.replace('" . $seblink . "')"); + } + + /** + * Check if we need to redirect to SEB config link. + * + * @return bool + */ + public function should_redirect_to_seb_config_link(): bool { + // We check if there is an existing config key header. If there is none, we assume that + // the SEB application is not using header verification so auto redirect should not proceed. + $haskeyinheader = !is_null($this->get_received_config_key()); + + return $this->is_using_seb() + && get_config('quizaccess_seb', 'autoreconfigureseb') + && $haskeyinheader; + } } diff --git a/mod/quiz/accessrule/seb/classes/event/access_prevented.php b/mod/quiz/accessrule/seb/classes/event/access_prevented.php index c8c36bd4009..22aba91ec9f 100644 --- a/mod/quiz/accessrule/seb/classes/event/access_prevented.php +++ b/mod/quiz/accessrule/seb/classes/event/access_prevented.php @@ -46,16 +46,20 @@ class access_prevented extends base { * * @param access_manager $accessmanager Access manager. * @param string $reason Reason that access was prevented. + * @param string|null $configkey A Safe Exam Browser config key. + * @param string|null $browserexamkey A Safe Exam Browser browser exam key. * @return base */ - public static function create_strict(access_manager $accessmanager, string $reason) : base { + public static function create_strict(access_manager $accessmanager, string $reason, + ?string $configkey = null, ?string $browserexamkey = null) : base { global $USER; $other = []; $other['reason'] = $reason; $other['savedconfigkey'] = $accessmanager->get_valid_config_key(); - $other['receivedconfigkey'] = $accessmanager->get_received_config_key(); - $other['receivedbrowserexamkey'] = $accessmanager->get_received_browser_exam_key(); + $other['receivedconfigkey'] = !empty($configkey) ? $configkey : $accessmanager->get_received_config_key(); + $other['receivedbrowserexamkey'] = !empty($browserexamkey) ? $browserexamkey + : $accessmanager->get_received_browser_exam_key(); return self::create([ 'userid' => $USER->id, @@ -120,4 +124,4 @@ class access_prevented extends base { 'cmid' => ['db' => 'course_modules', 'restore' => 'course_modules'] ]; } -} \ No newline at end of file +} diff --git a/mod/quiz/accessrule/seb/classes/external/validate_quiz_keys.php b/mod/quiz/accessrule/seb/classes/external/validate_quiz_keys.php new file mode 100644 index 00000000000..2375e18ec63 --- /dev/null +++ b/mod/quiz/accessrule/seb/classes/external/validate_quiz_keys.php @@ -0,0 +1,155 @@ +. + +namespace quizaccess_seb\external; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +use external_api; +use external_function_parameters; +use external_single_structure; +use external_value; +use invalid_parameter_exception; +use quiz; +use quizaccess_seb\event\access_prevented; +use quizaccess_seb\access_manager; + +require_once($CFG->dirroot . '/mod/quiz/accessmanager.php'); +require_once($CFG->dirroot . '/mod/quiz/attemptlib.php'); +require_once($CFG->libdir . '/externallib.php'); + +/** + * Validate browser exam key and config key. + * + * @package quizaccess_seb + * @author Andrew Madden + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class validate_quiz_keys extends external_api { + + /** + * External function parameters. + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT, 'Course module ID', + VALUE_REQUIRED, null, NULL_NOT_ALLOWED), + 'url' => new external_value(PARAM_URL, 'Page URL to check', + VALUE_REQUIRED, null, NULL_NOT_ALLOWED), + 'configkey' => new external_value(PARAM_ALPHANUMEXT, 'SEB config key', + VALUE_DEFAULT, null), + 'browserexamkey' => new external_value(PARAM_ALPHANUMEXT, 'SEB browser exam key', + VALUE_DEFAULT, null), + ]); + } + + /** + * Validate a SEB config key or browser exam key. + * + * @param string $cmid Course module ID. + * @param string $url URL of the page on which the SEB JS API generated the keys. + * @param string|null $configkey A SEB config key hash. Includes URL in the hash. + * @param string|null $browserexamkey A SEB browser exam key hash. Includes the URL in the hash. + * @return array + */ + public static function execute(string $cmid, string $url, ?string $configkey = null, ?string $browserexamkey = null): array { + list( + 'cmid' => $cmid, + 'url' => $url, + 'configkey' => $configkey, + 'browserexamkey' => $browserexamkey + ) = self::validate_parameters(self::execute_parameters(), [ + 'cmid' => $cmid, + 'url' => $url, + 'configkey' => $configkey, + 'browserexamkey' => $browserexamkey, + ]); + + self::validate_context(\context_module::instance($cmid)); + + // At least one SEB key must be provided. + if (empty($configkey) && empty($browserexamkey)) { + throw new invalid_parameter_exception(get_string('error:ws:nokeyprovided', 'quizaccess_seb')); + } + + // Check quiz exists corresponding to cmid. + if (($quizid = self::get_quiz_id($cmid)) === 0) { + throw new invalid_parameter_exception(get_string('error:ws:quiznotexists', 'quizaccess_seb', $cmid)); + } + + $result = ['configkey' => true, 'browserexamkey' => true]; + + $accessmanager = new access_manager(quiz::create($quizid)); + + // Check if there is a valid config key. + if (!$accessmanager->validate_config_key($configkey, $url)) { + access_prevented::create_strict($accessmanager, get_string('invalid_config_key', 'quizaccess_seb'), + $configkey, $browserexamkey)->trigger(); + $result['configkey'] = false; + } + + // Check if there is a valid browser exam key. + if (!$accessmanager->validate_browser_exam_key($browserexamkey, $url)) { + access_prevented::create_strict($accessmanager, get_string('invalid_browser_key', 'quizaccess_seb'), + $configkey, $browserexamkey)->trigger(); + $result['browserexamkey'] = false; + } + + if ($result['configkey'] && $result['browserexamkey']) { + // Set the state of the access for this Moodle session. + $accessmanager->set_session_access(true); + } + + return $result; + } + + /** + * External function returns. + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'configkey' => new external_value(PARAM_BOOL, 'Is a provided config key valid?', + VALUE_REQUIRED, 0, NULL_NOT_ALLOWED), + 'browserexamkey' => new external_value(PARAM_BOOL, 'Is a provided browser exam key valid?', + VALUE_REQUIRED, 0, NULL_NOT_ALLOWED) + ]); + } + + /** + * Check if there is a valid quiz corresponding to a course module it. + * + * @param string $cmid Course module ID. + * @return int Returns quiz id if cmid matches valid quiz, or 0 if there is no match. + */ + private static function get_quiz_id(string $cmid): int { + $quizid = 0; + + $coursemodule = get_coursemodule_from_id('quiz', $cmid); + if (!empty($coursemodule)) { + $quizid = $coursemodule->instance; + } + + return $quizid; + } +} + diff --git a/mod/quiz/accessrule/seb/classes/quiz_settings.php b/mod/quiz/accessrule/seb/classes/quiz_settings.php index 2fb45cff75a..923fdb33d6d 100644 --- a/mod/quiz/accessrule/seb/classes/quiz_settings.php +++ b/mod/quiz/accessrule/seb/classes/quiz_settings.php @@ -516,6 +516,11 @@ class quiz_settings extends persistent { $quizurl = new moodle_url($CFG->wwwroot . "/mod/quiz/view.php", ['id' => $this->get('cmid')]); $this->plist->set_or_update_value('startURL', new CFString($quizurl->out(true))); $this->plist->set_or_update_value('sendBrowserExamKey', new CFBoolean(true)); + + // Use the modern WebView and JS API if the SEB version supports it. + // Documentation: https://safeexambrowser.org/developer/seb-config-key.html . + // "Set the key browserWindowWebView to the policy "Prefer Modern" (value 3)". + $this->plist->set_or_update_value('browserWindowWebView', new CFNumber(3)); } /** diff --git a/mod/quiz/accessrule/seb/db/services.php b/mod/quiz/accessrule/seb/db/services.php new file mode 100644 index 00000000000..d96df9f6582 --- /dev/null +++ b/mod/quiz/accessrule/seb/db/services.php @@ -0,0 +1,36 @@ +. + +/** + * List web services and external functions for plugin. + * + * @package quizaccess_seb + * @author Andrew Madden + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$functions = [ + 'quizaccess_seb_validate_quiz_keys' => [ + 'classname' => 'quizaccess_seb\external\validate_quiz_keys', + 'methodname' => 'execute', + 'description' => 'Validate a Safe Exam Browser config key or a browser exam key.', + 'type' => 'read', + 'ajax' => true, + ], +]; diff --git a/mod/quiz/accessrule/seb/lang/en/quizaccess_seb.php b/mod/quiz/accessrule/seb/lang/en/quizaccess_seb.php index 4d64533f720..b0871991c82 100644 --- a/mod/quiz/accessrule/seb/lang/en/quizaccess_seb.php +++ b/mod/quiz/accessrule/seb/lang/en/quizaccess_seb.php @@ -35,6 +35,7 @@ $string['cachedef_configkey'] = 'SEB config key cache'; $string['cachedef_quizsettings'] = 'SEB quiz settings cache'; $string['cantdelete'] = 'The template can\'t be deleted as it has been used for one or more quizzes.'; $string['cantedit'] = 'The template can\'t be edited as it has been used for one or more quizzes.'; +$string['checkingaccess'] = 'Checking access to Safe Exam Browser...'; $string['clientrequiresseb'] = 'This quiz has been configured to use the Safe Exam Browser with client configuration.'; $string['confirmtemplateremovalquestion'] = 'Are you sure you want to remove this template?'; $string['confirmtemplateremovaltitle'] = 'Confirm template removal?'; @@ -47,6 +48,8 @@ $string['downloadsebconfig'] = 'Download SEB config file'; $string['duplicatetemplate'] = 'A template with the same name already exists.'; $string['edittemplate'] = 'Edit template'; $string['enabled'] = 'Enabled'; +$string['error:ws:nokeyprovided'] = 'At least one SEB key must be provided.'; +$string['error:ws:quiznotexists'] = 'Quiz not found matching course module id: {$a}'; $string['event:accessprevented'] = "Quiz access was prevented"; $string['event:templatecreated'] = 'SEB template was created'; $string['event:templatedeleted'] = 'SEB template was deleted'; @@ -177,6 +180,7 @@ $string['seb_use_upload'] = 'Yes – Upload my own config'; $string['seb_userconfirmquit'] = 'Ask user to confirm quitting'; $string['seb_userconfirmquit_help'] = 'If enabled, users have to confirm quitting of SEB when a quit link is detected.'; $string['sebdownloadbutton'] = 'Download Safe Exam Browser'; +$string['sebkeysvalidationfailed'] = 'SEB keys validation failed'; $string['seblinkbutton'] = 'Launch Safe Exam Browser'; $string['sebrequired'] = "This quiz has been configured so that students may only attempt it using the Safe Exam Browser."; $string['setting:autoreconfigureseb'] = 'Auto-configure SEB'; diff --git a/mod/quiz/accessrule/seb/rule.php b/mod/quiz/accessrule/seb/rule.php index 9b5ed7a329c..1772a818237 100644 --- a/mod/quiz/accessrule/seb/rule.php +++ b/mod/quiz/accessrule/seb/rule.php @@ -298,25 +298,33 @@ class quizaccess_seb extends quiz_access_rule_base { $PAGE->set_pagelayout('secure'); $this->prevent_display_blocks(); - if ($this->accessmanager->should_validate_basic_header() && !$this->accessmanager->validate_basic_header()) { + // Access has previously been validated for this session and quiz. + if ($this->accessmanager->validate_session_access()) { + return false; + } + + if (!$this->accessmanager->validate_basic_header()) { access_prevented::create_strict($this->accessmanager, $this->get_reason_text('not_seb'))->trigger(); return $this->get_require_seb_error_message(); } - if ($this->accessmanager->should_validate_config_key() && !$this->accessmanager->validate_config_key()) { - if ($this->should_redirect_to_seb_config_link()) { - $this->redirect_to_seb_config_link(); + if (!$this->accessmanager->validate_config_key()) { + if ($this->accessmanager->should_redirect_to_seb_config_link()) { + $this->accessmanager->redirect_to_seb_config_link(); } access_prevented::create_strict($this->accessmanager, $this->get_reason_text('invalid_config_key'))->trigger(); return $this->get_invalid_key_error_message(); } - if ($this->accessmanager->should_validate_browser_exam_key() && !$this->accessmanager->validate_browser_exam_keys()) { + if (!$this->accessmanager->validate_browser_exam_key()) { access_prevented::create_strict($this->accessmanager, $this->get_reason_text('invalid_browser_key'))->trigger(); return $this->get_invalid_key_error_message(); } + // Set the state of the access for this Moodle session. + $this->accessmanager->set_session_access(true); + return false; } @@ -431,6 +439,8 @@ class quizaccess_seb extends quiz_access_rule_base { * (may be '' if no message is appropriate). */ public function description() : array { + global $PAGE; + $messages = [get_string('sebrequired', 'quizaccess_seb')]; // Display download SEB config link for those who can bypass using SEB. @@ -441,6 +451,9 @@ class quizaccess_seb extends quiz_access_rule_base { // Those with higher level access will be able to see the button if they've made an attempt. if (!$this->prevent_access()) { $messages[] = $this->display_buttons($this->get_quit_button()); + } else { + $PAGE->requires->js_call_amd('quizaccess_seb/validate_quiz_access', 'init', + [$this->quiz->cmid, (bool)get_config('quizaccess_seb', 'autoreconfigureseb')]); } return $messages; @@ -459,6 +472,13 @@ class quizaccess_seb extends quiz_access_rule_base { $page->set_pagelayout('secure'); } + /** + * This is called when the current attempt at the quiz is finished. + */ + public function current_attempt_finished() { + $this->accessmanager->clear_session_access(); + } + /** * Prepare buttons HTML code for being displayed on the screen. * @@ -575,23 +595,4 @@ class quizaccess_seb extends quiz_access_rule_base { private function should_display_download_seb_link() : bool { return !empty($this->quiz->seb_showsebdownloadlink); } - - /** - * Redirect to SEB config link. This will force Safe Exam Browser to be reconfigured. - */ - private function redirect_to_seb_config_link() { - global $PAGE; - - $seblink = \quizaccess_seb\link_generator::get_link($this->quiz->cmid, true, is_https()); - $PAGE->requires->js_amd_inline("document.location.replace('" . $seblink . "')"); - } - - /** - * Check if we need to redirect to SEB config link. - * @return bool - */ - private function should_redirect_to_seb_config_link() : bool { - return $this->accessmanager->is_using_seb() && get_config('quizaccess_seb', 'autoreconfigureseb'); - } - } diff --git a/mod/quiz/accessrule/seb/templates/loading.mustache b/mod/quiz/accessrule/seb/templates/loading.mustache new file mode 100644 index 00000000000..81689185ae2 --- /dev/null +++ b/mod/quiz/accessrule/seb/templates/loading.mustache @@ -0,0 +1,34 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template quizaccess_seb/loading + + Loading container. + + Example context (json): + { + "extraclasses": "class" + } +}} + diff --git a/mod/quiz/accessrule/seb/tests/access_manager_test.php b/mod/quiz/accessrule/seb/tests/access_manager_test.php index 26c289d6af3..3a7743add70 100644 --- a/mod/quiz/accessrule/seb/tests/access_manager_test.php +++ b/mod/quiz/accessrule/seb/tests/access_manager_test.php @@ -27,6 +27,7 @@ require_once(__DIR__ . '/test_helper_trait.php'); * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \quizaccess_seb\access_manager */ class access_manager_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; @@ -95,6 +96,24 @@ class access_manager_test extends \advanced_testcase { $this->assertTrue($accessmanager->can_bypass_seb()); } + /** + * Test that user has capability to bypass SEB check. + */ + public function test_admin_user_can_bypass_seb_check() { + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); + + // Test normal user cannot bypass check. + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + $accessmanager = $this->get_access_manager(); + $this->assertFalse($accessmanager->can_bypass_seb()); + + // Test with admin user. + $this->setAdminUser(); + $accessmanager = $this->get_access_manager(); + $this->assertTrue($accessmanager->can_bypass_seb()); + } + /** * Test user does not have capability to bypass SEB check. */ @@ -141,10 +160,23 @@ class access_manager_test extends \advanced_testcase { $expectedhash = hash('sha256', $FULLME . $configkey); $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = $expectedhash; - $this->assertTrue($accessmanager->validate_browser_exam_keys()); $this->assertTrue($accessmanager->validate_config_key()); } + /** + * Test that the quiz Config Key matches a provided config key with no incoming request header. + */ + public function test_access_keys_validate_with_provided_config_key() { + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); + $url = 'https://www.example.com/moodle'; + $accessmanager = $this->get_access_manager(); + + $configkey = quiz_settings::get_record(['quizid' => $this->quiz->id])->get_config_key(); + $fullconfigkey = hash('sha256', $url . $configkey); + + $this->assertTrue($accessmanager->validate_config_key($fullconfigkey, $url)); + } + /** * Test that the quiz Config Key does not match the incoming request header. */ @@ -153,7 +185,6 @@ class access_manager_test extends \advanced_testcase { $accessmanager = $this->get_access_manager(); $this->assertFalse($accessmanager->validate_config_key()); - $this->assertTrue($accessmanager->validate_browser_exam_keys()); } /** @@ -162,22 +193,21 @@ class access_manager_test extends \advanced_testcase { public function test_config_key_not_checked_if_client_requirement_is_selected() { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $accessmanager = $this->get_access_manager(); - $this->assertTrue($accessmanager->validate_config_key()); - $this->assertTrue($accessmanager->validate_browser_exam_keys()); + $this->assertFalse($accessmanager->should_validate_config_key()); } /** * Test that if there are no browser exam keys for quiz, check is skipped. */ - public function test_no_browser_exam_keys_cause_check_to_be_skipped() { + public function test_no_browser_exam_keys_cause_check_to_be_successful() { $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); $settings = quiz_settings::get_record(['quizid' => $this->quiz->id]); $settings->set('allowedbrowserexamkeys', ''); $settings->save(); $accessmanager = $this->get_access_manager(); - $this->assertTrue($accessmanager->validate_config_key()); - $this->assertTrue($accessmanager->validate_browser_exam_keys()); + $this->assertTrue($accessmanager->should_validate_browser_exam_key()); + $this->assertTrue($accessmanager->validate_browser_exam_key()); } /** @@ -190,8 +220,7 @@ class access_manager_test extends \advanced_testcase { $settings->set('allowedbrowserexamkeys', hash('sha256', 'one') . "\n" . hash('sha256', 'two')); $settings->save(); $accessmanager = $this->get_access_manager(); - $this->assertTrue($accessmanager->validate_config_key()); - $this->assertFalse($accessmanager->validate_browser_exam_keys()); + $this->assertFalse($accessmanager->validate_browser_exam_key()); } /** @@ -205,8 +234,7 @@ class access_manager_test extends \advanced_testcase { $settings->save(); $accessmanager = $this->get_access_manager(); $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = hash('sha256', 'notwhatyouwereexpectinghuh'); - $this->assertTrue($accessmanager->validate_config_key()); - $this->assertFalse($accessmanager->validate_browser_exam_keys()); + $this->assertFalse($accessmanager->validate_browser_exam_key()); } /** @@ -226,8 +254,23 @@ class access_manager_test extends \advanced_testcase { $FULLME = 'https://example.com/moodle/mod/quiz/attempt.php?attemptid=123&page=4'; $expectedhash = hash('sha256', $FULLME . $browserexamkey); $_SERVER['HTTP_X_SAFEEXAMBROWSER_REQUESTHASH'] = $expectedhash; - $this->assertTrue($accessmanager->validate_config_key()); - $this->assertTrue($accessmanager->validate_browser_exam_keys()); + $this->assertTrue($accessmanager->validate_browser_exam_key()); + } + + /** + * Test that browser exam key matches a provided browser exam key. + */ + public function test_browser_exam_keys_match_provided_browser_exam_key() { + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); + $url = 'https://www.example.com/moodle'; + $settings = quiz_settings::get_record(['quizid' => $this->quiz->id]); + $browserexamkey = hash('sha256', 'browserexamkey'); + $fullbrowserexamkey = hash('sha256', $url . $browserexamkey); + $settings->set('allowedbrowserexamkeys', $browserexamkey); // Add a hashed BEK. + $settings->save(); + $accessmanager = $this->get_access_manager(); + + $this->assertTrue($accessmanager->validate_browser_exam_key($fullbrowserexamkey, $url)); } /** @@ -441,4 +484,105 @@ class access_manager_test extends \advanced_testcase { } + /** + * Test if config key should not be validated. + */ + public function test_if_config_key_should_not_be_validated() { + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_NO); + $accessmanager = $this->get_access_manager(); + + $this->assertTrue($accessmanager->validate_config_key()); + } + + /** + * Test if browser exam key should not be validated. + */ + public function test_if_browser_exam_key_should_not_be_validated() { + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); + $accessmanager = $this->get_access_manager(); + + $this->assertTrue($accessmanager->validate_browser_exam_key()); + } + + /** + * Test that access is set correctly in Moodle session. + */ + public function test_set_session_access() { + global $SESSION; + + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); + $accessmanager = $this->get_access_manager(); + + $this->assertTrue(empty($SESSION->quizaccess_seb_access[$this->quiz->cmid])); + + $accessmanager->set_session_access(true); + + $this->assertTrue($SESSION->quizaccess_seb_access[$this->quiz->cmid]); + } + + /** + * Test that access is set in Moodle session for only course module associated with access manager. + */ + public function test_session_access_set_for_specific_course_module() { + global $SESSION; + + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); + $quiz2 = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); + $accessmanager = $this->get_access_manager(); + + $accessmanager->set_session_access(true); + + $this->assertCount(1, $SESSION->quizaccess_seb_access); + $this->assertTrue($SESSION->quizaccess_seb_access[$this->quiz->cmid]); + $this->assertTrue(empty($SESSION->quizaccess_seb_access[$quiz2->cmid])); + } + + /** + * Test that access state can be retrieved from Moodle session. + */ + public function test_validate_session_access() { + global $SESSION; + + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); + $accessmanager = $this->get_access_manager(); + + $this->assertEmpty($accessmanager->validate_session_access()); + + $SESSION->quizaccess_seb_access[$this->quiz->cmid] = true; + + $this->assertTrue($accessmanager->validate_session_access()); + } + + /** + * Test that access can be cleared from Moodle session. + */ + public function test_clear_session_access() { + global $SESSION; + + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); + $accessmanager = $this->get_access_manager(); + + $SESSION->quizaccess_seb_access[$this->quiz->cmid] = true; + + $accessmanager->clear_session_access(); + + $this->assertTrue(empty($SESSION->quizaccess_seb_access[$this->quiz->cmid])); + } + + /** + * Test we can decide if need to redirect to SEB config link. + */ + public function test_should_redirect_to_seb_config_link() { + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); + $accessmanager = $this->get_access_manager(); + + set_config('autoreconfigureseb', '1', 'quizaccess_seb'); + $_SERVER['HTTP_USER_AGENT'] = 'SEB'; + $this->assertFalse($accessmanager->should_redirect_to_seb_config_link()); + + set_config('autoreconfigureseb', '1', 'quizaccess_seb'); + $_SERVER['HTTP_USER_AGENT'] = 'SEB'; + $_SERVER['HTTP_X_SAFEEXAMBROWSER_CONFIGKEYHASH'] = hash('sha256', 'configkey'); + $this->assertTrue($accessmanager->should_redirect_to_seb_config_link()); + } } diff --git a/mod/quiz/accessrule/seb/tests/event/events_test.php b/mod/quiz/accessrule/seb/tests/event/events_test.php index 346f816198f..f96c951d974 100644 --- a/mod/quiz/accessrule/seb/tests/event/events_test.php +++ b/mod/quiz/accessrule/seb/tests/event/events_test.php @@ -45,6 +45,8 @@ class events_test extends \advanced_testcase { /** * Test creating the access_prevented event. + * + * @covers \quizaccess_seb\event\access_prevented */ public function test_event_access_prevented() { $this->resetAfterTest(); @@ -91,8 +93,58 @@ class events_test extends \advanced_testcase { $this->assertEquals('browserexamkey', $event->other['receivedbrowserexamkey']); } + /** + * Test creating the access_prevented event with provided SEB keys. + * + * @covers \quizaccess_seb\event\access_prevented + */ + public function test_event_access_prevented_with_keys() { + $this->resetAfterTest(); + + $this->setAdminUser(); + $quiz = $this->create_test_quiz($this->course, \quizaccess_seb\settings_provider::USE_SEB_CONFIG_MANUALLY); + $accessmanager = new \quizaccess_seb\access_manager(new quiz($quiz, + get_coursemodule_from_id('quiz', $quiz->cmid), $this->course)); + + // Set up event with data. + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $event = \quizaccess_seb\event\access_prevented::create_strict($accessmanager, 'Because I said so.', + 'configkey', 'browserexamkey'); + + // Create an event sink, trigger event and retrieve event. + $sink = $this->redirectEvents(); + $event->trigger(); + $events = $sink->get_events(); + $this->assertEquals(1, count($events)); + $event = reset($events); + + $expectedconfigkey = $accessmanager->get_valid_config_key(); + + // Test that the event data is as expected. + $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); + $this->assertEquals('Quiz access was prevented', $event->get_name()); + $this->assertEquals( + "The user with id '$user->id' has been prevented from accessing quiz with id '$quiz->id' by the " + . "Safe Exam Browser access plugin. The reason was 'Because I said so.'. " + . "Expected config key: '$expectedconfigkey'. " + . "Received config key: 'configkey'. Received browser exam key: 'browserexamkey'.", + $event->get_description()); + $this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context()); + $this->assertEquals($user->id, $event->userid); + $this->assertEquals($quiz->id, $event->objectid); + $this->assertEquals($this->course->id, $event->courseid); + $this->assertEquals('Because I said so.', $event->other['reason']); + $this->assertEquals($expectedconfigkey, $event->other['savedconfigkey']); + $this->assertEquals('configkey', $event->other['receivedconfigkey']); + $this->assertEquals('browserexamkey', $event->other['receivedbrowserexamkey']); + } + /** * Test creating the template_created event. + * + * @covers \quizaccess_seb\event\template_created */ public function test_event_create_template() { $this->resetAfterTest(); diff --git a/mod/quiz/accessrule/seb/tests/external/validate_quiz_access_test.php b/mod/quiz/accessrule/seb/tests/external/validate_quiz_access_test.php new file mode 100644 index 00000000000..4767e654b46 --- /dev/null +++ b/mod/quiz/accessrule/seb/tests/external/validate_quiz_access_test.php @@ -0,0 +1,260 @@ +. + +namespace quizaccess_seb\external; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +use quizaccess_seb\quiz_settings; + +require_once($CFG->libdir . '/externallib.php'); + +/** + * PHPUnit tests for external function. + * + * @package quizaccess_seb + * @author Andrew Madden + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \quizaccess_seb\external\validate_quiz_access + */ +class validate_quiz_access_test extends \advanced_testcase { + use \quizaccess_seb_test_helper_trait; + + /** + * This method runs before every test. + */ + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + + // Generate data objects. + $this->course = $this->getDataGenerator()->create_course(); + $this->quiz = $this->create_test_quiz($this->course); + $this->user = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($this->user->id, $this->course->id, 'student'); + $this->setUser($this->user); + } + + /** + * Bad parameter provider. + * + * @return array + */ + public function bad_parameters_provider(): array { + return [ + 'no params' => [ + 'cmid' => null, + 'url' => null, + 'configkey' => null, + '/Invalid parameter value detected \(Missing required key in single structure: cmid\)/' + ], + 'no course module id' => [ + 'cmid' => null, + 'url' => 'https://www.example.com/moodle', + 'configkey' => hash('sha256', 'configkey'), + '/Invalid parameter value detected \(Missing required key in single structure: cmid\)/' + ], + 'no url' => [ + 'cmid' => 123, + 'url' => null, + 'configkey' => hash('sha256', 'configkey'), + '/Invalid parameter value detected \(Missing required key in single structure: url\)/' + ], + 'cmid is not an int' => [ + 'cmid' => 'test', + 'url' => 'https://www.example.com/moodle', + 'configkey' => null, + '/Invalid external api parameter: the value is "test", the server was expecting "int" type/' + ], + 'url is not a url' => [ + 'cmid' => 123, + 'url' => 123, + 'configkey' => hash('sha256', 'configkey'), + '/Invalid external api parameter: the value is "123", the server was expecting "url" type/' + ], + ]; + } + + /** + * Test exception thrown for bad parameters. + * + * @param mixed $cmid Course module id. + * @param mixed $url Page URL. + * @param mixed $configkey SEB config key. + * @param mixed $messageregex Error message regex to check. + * + * @dataProvider bad_parameters_provider + */ + public function test_invalid_parameters($cmid, $url, $configkey, $messageregex) { + $params = []; + if (!empty($cmid)) { + $params['cmid'] = $cmid; + } + if (!empty($url)) { + $params['url'] = $url; + } + if (!empty($configkey)) { + $params['configkey'] = $configkey; + } + + $this->expectException(\invalid_parameter_exception::class); + $this->expectExceptionMessageMatches($messageregex); + \external_api::validate_parameters(validate_quiz_keys::execute_parameters(), $params); + } + + /** + * Test that the user has permissions to access context. + */ + public function test_context_is_not_valid_for_user() { + // Set user as user not enrolled in course and quiz. + $this->user = $this->getDataGenerator()->create_user(); + $this->setUser($this->user); + + $this->expectException(\require_login_exception::class); + $this->expectExceptionMessage('Course or activity not accessible. (Not enrolled)'); + validate_quiz_keys::execute($this->quiz->cmid, 'https://www.example.com/moodle', 'configkey'); + } + + /** + * Test exception thrown when no key provided. + */ + public function test_no_keys_provided() { + $this->expectException(\invalid_parameter_exception::class); + $this->expectExceptionMessage('At least one SEB key must be provided.'); + validate_quiz_keys::execute($this->quiz->cmid, 'https://www.example.com/moodle'); + } + + /** + * Test exception thrown if cmid doesn't match a quiz. + */ + public function test_quiz_does_not_exist() { + $this->setAdminUser(); + $forum = $this->getDataGenerator()->create_module('forum', ['course' => $this->course->id]); + $this->expectException(\invalid_parameter_exception::class); + $this->expectExceptionMessage('Quiz not found matching course module id: ' . $forum->cmid); + validate_quiz_keys::execute($forum->cmid, 'https://www.example.com/moodle', 'configkey'); + } + + /** + * Test config key is valid. + */ + public function test_config_key_valid() { + $sink = $this->redirectEvents(); + // Test settings to populate the quiz. + $settings = $this->get_test_settings([ + 'quizid' => $this->quiz->id, + 'cmid' => $this->quiz->cmid, + ]); + $url = 'https://www.example.com/moodle'; + + // Create the quiz settings. + $quizsettings = new quiz_settings(0, $settings); + $quizsettings->save(); + + $fullconfigkey = hash('sha256', $url . $quizsettings->get_config_key()); + $result = validate_quiz_keys::execute($this->quiz->cmid, $url, $fullconfigkey); + $this->assertTrue($result['configkey']); + $this->assertTrue($result['browserexamkey']); + + $events = $sink->get_events(); + $this->assertCount(0, $events); + } + + /** + * Test config key is not valid. + */ + public function test_config_key_not_valid() { + $sink = $this->redirectEvents(); + // Test settings to populate the quiz. + $settings = $this->get_test_settings([ + 'quizid' => $this->quiz->id, + 'cmid' => $this->quiz->cmid, + ]); + + // Create the quiz settings. + $quizsettings = new quiz_settings(0, $settings); + $quizsettings->save(); + + $result = validate_quiz_keys::execute($this->quiz->cmid, 'https://www.example.com/moodle', 'badconfigkey'); + $this->assertFalse($result['configkey']); + $this->assertTrue($result['browserexamkey']); + $events = $sink->get_events(); + $this->assertCount(1, $events); + $event = reset($events); + $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); + $this->assertStringContainsString('Invalid SEB config key', $event->get_description()); + } + + /** + * Test browser exam key is valid. + */ + public function test_browser_exam_key_valid() { + $sink = $this->redirectEvents(); + // Test settings to populate the quiz. + $url = 'https://www.example.com/moodle'; + $validbrowserexamkey = hash('sha256', 'validbrowserexamkey'); + $settings = $this->get_test_settings([ + 'quizid' => $this->quiz->id, + 'cmid' => $this->quiz->cmid, + 'requiresafeexambrowser' => \quizaccess_seb\settings_provider::USE_SEB_CLIENT_CONFIG, + 'allowedbrowserexamkeys' => $validbrowserexamkey, + ]); + + // Create the quiz settings. + $quizsettings = new quiz_settings(0, $settings); + $quizsettings->save(); + + $fullbrowserexamkey = hash('sha256', $url . $validbrowserexamkey); + $result = validate_quiz_keys::execute($this->quiz->cmid, $url, null, $fullbrowserexamkey); + $this->assertTrue($result['configkey']); + $this->assertTrue($result['browserexamkey']); + $events = $sink->get_events(); + $this->assertCount(0, $events); + } + + /** + * Test browser exam key is not valid. + */ + public function test_browser_exam_key_not_valid() { + $sink = $this->redirectEvents(); + // Test settings to populate the quiz. + $validbrowserexamkey = hash('sha256', 'validbrowserexamkey'); + $settings = $this->get_test_settings([ + 'quizid' => $this->quiz->id, + 'cmid' => $this->quiz->cmid, + 'requiresafeexambrowser' => \quizaccess_seb\settings_provider::USE_SEB_CLIENT_CONFIG, + 'allowedbrowserexamkeys' => $validbrowserexamkey, + ]); + + // Create the quiz settings. + $quizsettings = new quiz_settings(0, $settings); + $quizsettings->save(); + + $result = validate_quiz_keys::execute($this->quiz->cmid, 'https://www.example.com/moodle', null, + hash('sha256', 'badbrowserexamkey')); + $this->assertTrue($result['configkey']); + $this->assertFalse($result['browserexamkey']); + $events = $sink->get_events(); + $this->assertCount(1, $events); + $event = reset($events); + $this->assertInstanceOf('\quizaccess_seb\event\access_prevented', $event); + $this->assertStringContainsString('Invalid SEB browser key', $event->get_description()); + } + +} diff --git a/mod/quiz/accessrule/seb/tests/helper_test.php b/mod/quiz/accessrule/seb/tests/helper_test.php index fb577f0ce2a..96f23e37045 100644 --- a/mod/quiz/accessrule/seb/tests/helper_test.php +++ b/mod/quiz/accessrule/seb/tests/helper_test.php @@ -180,7 +180,8 @@ class helper_test extends \advanced_testcase { . "audioMuteallowSpellCheckbrowserWindowAllowReload" . "URLFilterEnableURLFilterEnableContentFilter" . "URLFilterRulesstartURL$url" - . "sendBrowserExamKeyexamSessionClearCookiesOnStart" + . "sendBrowserExamKeybrowserWindowWebView3" + . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", $config); } diff --git a/mod/quiz/accessrule/seb/tests/quiz_settings_test.php b/mod/quiz/accessrule/seb/tests/quiz_settings_test.php index 88b1d3c86be..57335a77f8d 100644 --- a/mod/quiz/accessrule/seb/tests/quiz_settings_test.php +++ b/mod/quiz/accessrule/seb/tests/quiz_settings_test.php @@ -60,9 +60,10 @@ class quiz_settings_test extends \advanced_testcase { */ public function test_config_is_created_from_quiz_settings() { // Test settings to populate the in the object. - $settings = $this->get_test_settings(); - $settings->quizid = $this->quiz->id; - $settings->cmid = $this->quiz->cmid; + $settings = $this->get_test_settings([ + 'quizid' => $this->quiz->id, + 'cmid' => $this->quiz->cmid, + ]); // Obtain the existing record that is created when using a generator. $quizsettings = quiz_settings::get_record(['quizid' => $this->quiz->id]); @@ -83,8 +84,8 @@ class quiz_settings_test extends \advanced_testcase { . "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08URLFilterRules" . "action1activeexpression" . "test.comregexstartURL$this->url" - . "sendBrowserExamKeyexamSessionClearCookiesOnStart" - . "allowPreferencesWindow\n", + . "sendBrowserExamKeybrowserWindowWebView3" + . "examSessionClearCookiesOnStartallowPreferencesWindow\n", $config); } @@ -93,9 +94,10 @@ class quiz_settings_test extends \advanced_testcase { */ public function test_config_is_updated_from_quiz_settings() { // Test settings to populate the in the object. - $settings = $this->get_test_settings(); - $settings->quizid = $this->quiz->id; - $settings->cmid = $this->quiz->cmid; + $settings = $this->get_test_settings([ + 'quizid' => $this->quiz->id, + 'cmid' => $this->quiz->cmid, + ]); // Obtain the existing record that is created when using a generator. $quizsettings = quiz_settings::get_record(['quizid' => $this->quiz->id]); @@ -115,7 +117,8 @@ class quiz_settings_test extends \advanced_testcase { . "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08URLFilterRules" . "action1activeexpression" . "test.comregexstartURL$this->url" - . "sendBrowserExamKeyexamSessionClearCookiesOnStart" + . "sendBrowserExamKeybrowserWindowWebView3" + . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", $config); $quizsettings->set('filterembeddedcontent', 1); // Alter the settings. @@ -131,7 +134,8 @@ class quiz_settings_test extends \advanced_testcase { . "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08URLFilterRules" . "action1activeexpression" . "test.comregexstartURL$this->url" - . "sendBrowserExamKeyexamSessionClearCookiesOnStart" + . "sendBrowserExamKeybrowserWindowWebView3" + . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", $config); } @@ -143,7 +147,7 @@ class quiz_settings_test extends \advanced_testcase { $quizsettings = new quiz_settings(0, $settings); $configkey = $quizsettings->get_config_key(); - $this->assertEquals("b35510bd754f9d106ff88b9d2dc1bb297cddc9fc7b4bdde2dbda4e7d9e4b50d8", + $this->assertEquals("65ff7a3b8aec80e58fbe2e7968826c33cbf0ac444a748055ebe665829cbf4201", $configkey ); } @@ -156,12 +160,12 @@ class quiz_settings_test extends \advanced_testcase { $quizsettings = new quiz_settings(0, $settings); $configkey = $quizsettings->get_config_key(); - $this->assertEquals("b35510bd754f9d106ff88b9d2dc1bb297cddc9fc7b4bdde2dbda4e7d9e4b50d8", + $this->assertEquals("65ff7a3b8aec80e58fbe2e7968826c33cbf0ac444a748055ebe665829cbf4201", $configkey); $quizsettings->set('filterembeddedcontent', 1); // Alter the settings. $configkey = $quizsettings->get_config_key(); - $this->assertEquals("58010792504cccc18f7b0e5c9680fe60b567e8c1b5fb9798654cc9bad9ddf30c", + $this->assertEquals("d975b8a2ec4472495a8be7c64d7c8cc960dbb62472d5e88a8847ac0e5d77e533", $configkey); } @@ -214,7 +218,7 @@ class quiz_settings_test extends \advanced_testcase { . "\n" . "hashedQuitPasswordhashedpassword" . "allowWlanstartURL$url" - . "sendBrowserExamKey\n"; + . "sendBrowserExamKeybrowserWindowWebView3\n"; $itemid = $this->create_module_test_file($xml, $this->quiz->cmid); $quizsettings = quiz_settings::get_record(['quizid' => $this->quiz->id]); $quizsettings->set('requiresafeexambrowser', settings_provider::USE_SEB_UPLOAD_CONFIG); @@ -566,7 +570,8 @@ class quiz_settings_test extends \advanced_testcase { . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" - . "sendBrowserExamKeyexamSessionClearCookiesOnStart" + . "sendBrowserExamKeybrowserWindowWebView3" + . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], 'blocked simple expessions' => [ @@ -593,7 +598,8 @@ class quiz_settings_test extends \advanced_testcase { . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" - . "sendBrowserExamKeyexamSessionClearCookiesOnStart" + . "sendBrowserExamKeybrowserWindowWebView3" + . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], 'enabled regex expessions' => [ @@ -620,7 +626,8 @@ class quiz_settings_test extends \advanced_testcase { . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" - . "sendBrowserExamKeyexamSessionClearCookiesOnStart" + . "sendBrowserExamKeybrowserWindowWebView3" + . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], 'blocked regex expessions' => [ @@ -647,7 +654,8 @@ class quiz_settings_test extends \advanced_testcase { . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" - . "sendBrowserExamKeyexamSessionClearCookiesOnStart" + . "sendBrowserExamKeybrowserWindowWebView3" + . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], 'multiple simple expessions' => [ @@ -676,7 +684,8 @@ class quiz_settings_test extends \advanced_testcase { . "activeexpression" . "second.helloregex" . "startURLhttps://www.example.com/moodle/mod/quiz/view.php?id=1" - . "sendBrowserExamKeyexamSessionClearCookiesOnStart" + . "sendBrowserExamKeybrowserWindowWebView3" + . "examSessionClearCookiesOnStart" . "allowPreferencesWindow\n", ], ]; diff --git a/mod/quiz/accessrule/seb/tests/rule_test.php b/mod/quiz/accessrule/seb/tests/rule_test.php index 001f32a5421..b36c20b24c9 100644 --- a/mod/quiz/accessrule/seb/tests/rule_test.php +++ b/mod/quiz/accessrule/seb/tests/rule_test.php @@ -29,6 +29,7 @@ require_once(__DIR__ . '/test_helper_trait.php'); * @author Andrew Madden * @copyright 2020 Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \quizaccess_seb */ class rule_test extends \advanced_testcase { use \quizaccess_seb_test_helper_trait; @@ -43,6 +44,16 @@ class rule_test extends \advanced_testcase { $this->course = $this->getDataGenerator()->create_course(); } + /** + * Called after every test. + */ + public function tearDown(): void { + global $SESSION; + + if (!empty($this->quiz)) { + unset($SESSION->quizaccess_seb_access); + } + } /** * Helper method to get SEB download link for testing. @@ -558,6 +569,24 @@ class rule_test extends \advanced_testcase { $this->assertFalse($this->make_rule()->prevent_access()); } + public function test_access_allowed_if_access_state_stored_in_session() { + global $SESSION; + + $this->setAdminUser(); + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CLIENT_CONFIG); + + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + // Check that access is prevented. + $this->check_invalid_basic_header(); + + $SESSION->quizaccess_seb_access = [$this->quiz->cmid => true]; + + // Check access is now not prevented. + $this->assertFalse($this->make_rule()->prevent_access()); + } + /** * A helper method to check invalid browser key. * @@ -730,6 +759,14 @@ class rule_test extends \advanced_testcase { // Set up basic dummy request. $_SERVER['HTTP_USER_AGENT'] = 'WRONG_TEST_SITE'; + // Create an event sink, trigger event and retrieve event. + $this->check_invalid_basic_header(); + } + + /** + * A helper method to check invalid basic header. + */ + protected function check_invalid_basic_header() { // Create an event sink, trigger event and retrieve event. $sink = $this->redirectEvents(); @@ -1224,30 +1261,19 @@ class rule_test extends \advanced_testcase { } /** - * Test we can decide if need to redirect to SEB config link. + * Test cleanup when quiz is completed. */ - public function test_should_redirect_to_seb_config_link() { + public function test_current_attempt_finished() { + global $SESSION; $this->setAdminUser(); + $this->quiz = $this->create_test_quiz($this->course, settings_provider::USE_SEB_CONFIG_MANUALLY); + $quizsettings = quiz_settings::get_record(['quizid' => $this->quiz->id]); + $quizsettings->save(); + // Set access for Moodle session. + $SESSION->quizaccess_seb_access = [$this->quiz->cmid => true]; + $this->make_rule()->current_attempt_finished(); - $reflection = new \ReflectionClass('quizaccess_seb'); - $method = $reflection->getMethod('should_redirect_to_seb_config_link'); - $method->setAccessible(true); - - set_config('autoreconfigureseb', '0', 'quizaccess_seb'); - $_SERVER['HTTP_USER_AGENT'] = 'TEST'; - $this->assertFalse($method->invoke($this->make_rule())); - - set_config('autoreconfigureseb', '0', 'quizaccess_seb'); - $_SERVER['HTTP_USER_AGENT'] = 'SEB'; - $this->assertFalse($method->invoke($this->make_rule())); - - set_config('autoreconfigureseb', '1', 'quizaccess_seb'); - $_SERVER['HTTP_USER_AGENT'] = 'TEST'; - $this->assertFalse($method->invoke($this->make_rule())); - - set_config('autoreconfigureseb', '1', 'quizaccess_seb'); - $_SERVER['HTTP_USER_AGENT'] = 'SEB'; - $this->assertTrue($method->invoke($this->make_rule())); + $this->assertTrue(empty($SESSION->quizaccess_seb_access[$this->quiz->cmid])); } } diff --git a/mod/quiz/accessrule/seb/tests/test_helper_trait.php b/mod/quiz/accessrule/seb/tests/test_helper_trait.php index 81cd75f52c7..f09c2a7a51e 100644 --- a/mod/quiz/accessrule/seb/tests/test_helper_trait.php +++ b/mod/quiz/accessrule/seb/tests/test_helper_trait.php @@ -278,8 +278,8 @@ trait quizaccess_seb_test_helper_trait { * * @return \stdClass Settings. */ - protected function get_test_settings() : \stdClass { - return (object) [ + protected function get_test_settings(array $settings = []) : \stdClass { + return (object) array_merge([ 'quizid' => 1, 'cmid' => 1, 'requiresafeexambrowser' => '1', @@ -303,7 +303,7 @@ trait quizaccess_seb_test_helper_trait { 'expressionsblocked' => '', 'regexblocked' => '', 'showsebdownloadlink' => '1', - ]; + ], $settings); } } diff --git a/mod/quiz/accessrule/seb/version.php b/mod/quiz/accessrule/seb/version.php index 2625087a47b..357fc6bd3ba 100644 --- a/mod/quiz/accessrule/seb/version.php +++ b/mod/quiz/accessrule/seb/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2022041900; +$plugin->version = 2022041901; $plugin->requires = 2022041200; $plugin->component = 'quizaccess_seb'; $plugin->maturity = MATURITY_STABLE;