MDL-12886 refactored function return full external function info and improved reporti...
[moodle.git] / webservice / lib.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * Web services utility functions and classes
20  *
21  * @package   webservice
22  * @copyright 2009 Moodle Pty Ltd (http://moodle.com)
23  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 require_once($CFG->libdir.'/externallib.php');
28 /**
29  * Exception indicating access control problem in web service call
30  */
31 class webservice_access_exception extends moodle_exception {
32     /**
33      * Constructor
34      */
35     function __construct($debuginfo) {
36         parent::__construct('accessexception', 'exception', '', null, $debuginfo);
37     }
38 }
40 function webservice_protocol_is_enabled($protocol) {
41     global $CFG;
43     if (empty($CFG->enablewebservices)) {
44         return false;
45     }
47     $active = explode(',', $CFG->webserviceprotocols);
49     return(in_array($protocol, $active));
50 }
52 /**
53  * Mandatory web service server interface
54  * @author Petr Skoda (skodak)
55  */
56 interface webservice_server {
57     /**
58      * Process request from client.
59      * @param bool $simple use simple authentication
60      * @return void
61      */
62     public function run($simple);
63 }
65 /**
66  * Special abstraction of our srvices that allows
67  * interaction with stock Zend ws servers.
68  * @author skodak
69  */
70 abstract class webservice_zend_server implements webservice_server {
72     /** @property string name of the zend server class */
73     protected $zend_class;
75     /** @property object Zend server instance */
76     protected $zend_server;
78     /** @property string $wsname name of the web server plugin */
79     protected $wsname = null;
81     /** @property bool $simple true if simple auth used */
82     protected $simple;
84     /** @property string $service_class virtual web service class with all functions user name execute, created on the fly */
85     protected $service_class;
87     /** @property object restricted context */
88     protected $restricted_context;
90     /**
91      * Contructor
92      */
93     public function __construct($zend_class) {
94         $this->zend_class = $zend_class;
95     }
97     /**
98      * Process request from client.
99      * @param bool $simple use simple authentication
100      * @return void
101      */
102     public function run($simple) {
103         $this->simple = $simple;
105         // we will probably need a lot of memory in some functions
106         @raise_memory_limit('128M');
108         // set some longer timeout, this script is not sending any output,
109         // this means we need to manually extend the timeout operations
110         // that need longer time to finish
111         external_api::set_timeout();
113         // set up exception handler first, we want to sent them back in correct format that
114         // the other system understands
115         // we do not need to call the original default handler because this ws handler does everything
116         set_exception_handler(array($this, 'exception_handler'));
118         // now create the instance of zend server
119         $this->init_zend_server();
121         // this sets up $USER and $SESSION and context restrictions
122         $this->authenticate_user();
124         // make a list of all functions user is allowed to excecute
125         $this->init_service_class();
127         // TODO: solve debugging level somehow
128         Zend_XmlRpc_Server_Fault::attachFaultException('moodle_exception');
130         // start the server
131         $this->zend_server->setClass($this->service_class);
132         $response = $this->zend_server->handle();
133 /*
134         $grrr = ob_get_clean();
135         error_log($grrr);
136         error_log($response);
137 */
138         // session cleanup
139         $this->session_cleanup();
141         //TODO: we need to send some headers too I guess
142         echo $response;
143         die;
144     }
146     /**
147      * Load virtual class needed for Zend api
148      * @return void
149      */
150     protected function init_service_class() {
151         global $USER, $DB;
153         // first ofall get a complete list of services user is allowed to access
154         if ($this->simple) {
155             // now make sure the function is listed in at least one service user is allowed to use
156             // allow access only if:
157             //  1/ entry in the external_services_users table if required
158             //  2/ validuntil not reached
159             //  3/ has capability if specified in service desc
160             //  4/ iprestriction
162             $sql = "SELECT s.*, NULL AS iprestriction
163                       FROM {external_services} s
164                       JOIN {external_services_functions} sf ON (sf.externalserviceid = s.id AND s.restrictedusers = 0)
165                      WHERE s.enabled = 1
167                      UNION
169                     SELECT s.*, su.iprestriction
170                       FROM {external_services} s
171                       JOIN {external_services_functions} sf ON (sf.externalserviceid = s.id AND s.restrictedusers = 1)
172                       JOIN {external_services_users} su ON (su.externalserviceid = s.id AND su.userid = :userid)
173                      WHERE s.enabled = 1 AND su.validuntil IS NULL OR su.validuntil < :now";
174             $params = array('userid'=>$USER->id, 'now'=>time());
175         } else {
177             //TODO: token may restrict access to one service only
178             die('not implemented yet');
179         }
181         $serviceids = array();
182         $rs = $DB->get_recordset_sql($sql, $params);
184         // now make sure user may access at least one service
185         $remoteaddr = getremoteaddr();
186         $allowed = false;
187         foreach ($rs as $service) {
188             if (isset($serviceids[$service->id])) {
189                 continue;
190             }
191             if ($service->requiredcapability and !has_capability($service->requiredcapability, $this->restricted_context)) {
192                 continue; // cap required, sorry
193             }
194             if ($service->iprestriction and !address_in_subnet($remoteaddr, $service->iprestriction)) {
195                 continue; // wrong request source ip, sorry
196             }
197             $serviceids[$service->id] = $service->id;
198         }
199         $rs->close();
201         // now get the list of all functions
202         if ($serviceids) {
203             list($serviceids, $params) = $DB->get_in_or_equal($serviceids);
204             $sql = "SELECT f.*
205                       FROM {external_functions} f
206                      WHERE f.name IN (SELECT sf.functionname
207                                         FROM {external_services_functions} sf
208                                        WHERE sf.externalserviceid $serviceids)";
209             $functions = $DB->get_records_sql($sql, $params);
210         } else {
211             $functions = array();
212         }
214         // now make the virtual WS class with all the fuctions for this particular user
215         $methods = '';
216         foreach ($functions as $function) {
217             $methods .= $this->get_virtual_method_code($function);
218         }
220         // let's use unique class name, there might be problem in unit tests
221         $classname = 'webservices_virtual_class_000000';
222         while(class_exists($classname)) {
223             $classname++;
224         }
226         $code = '
227 /**
228  * Virtual class web services for user id '.$USER->id.' in context '.$this->restricted_context->id.'.
229  */
230 class '.$classname.' {
231 '.$methods.'
233 ';
234         // load the virtual class definition into memory
235         eval($code);
236         $this->service_class = $classname;
237     }
239     /**
240      * returns virtual method code
241      * @param object $function
242      * @return string PHP code
243      */
244     protected function get_virtual_method_code($function) {
245         global $CFG;
247         $function = external_function_info($function);
249         $params      = array();
250         $params_desc = array();
251         foreach ($function->parameters_desc->keys as $name=>$keydesc) {
252             $params[]      = '$'.$name;
253             $type = 'string';
254             if ($keydesc instanceof external_value) {
255                 switch($keydesc->type) {
256                     case PARAM_BOOL: // 0 or 1 only for now
257                     case PARAM_INT:
258                         $type = 'int'; break;
259                     case PARAM_FLOAT;
260                         $type = 'double'; break;
261                     default:
262                         $type = 'string';
263                 }
264             } else if ($keydesc instanceof external_single_structure) {
265                 $type = 'struct';
266             } else if ($keydesc instanceof external_multiple_structure) {
267                 $type = 'array';
268             }
269             $params_desc[] = '     * @param '.$type.' $'.$name.' '.$keydesc->desc;
270         }
271         $params      = implode(', ', $params);
272         $params_desc = implode("\n", $params_desc);
274         if (is_null($function->returns_desc)) {
275             $return = '     * @return void';
276         } else {
277             $type = 'string';
278             if ($function->returns_desc instanceof external_value) {
279                 switch($function->returns_desc->type) {
280                     case PARAM_BOOL: // 0 or 1 only for now
281                     case PARAM_INT:
282                         $type = 'int'; break;
283                     case PARAM_FLOAT;
284                         $type = 'double'; break;
285                     default:
286                         $type = 'string';
287                 }
288             } else if ($function->returns_desc instanceof external_single_structure) {
289                 $type = 'struct';
290             } else if ($function->returns_desc instanceof external_multiple_structure) {
291                 $type = 'array';
292             }
293             $return = '     * @return '.$type.' '.$function->returns_desc->desc;
294         }
296         // now crate a virtual method that calls the ext implemenation
297         // TODO: add PHP docs and all missing info here
299         $code = '
300     /**
301      * '.$function->description.'
302      *
303 '.$params_desc.'
304 '.$return.'
305      */
306     public function '.$function->name.'('.$params.') {
307         return '.$function->classname.'::'.$function->methodname.'('.$params.');
308     }
309 ';
310         return $code;
311     }
313     /**
314      * Set up zend serice class
315      * @return void
316      */
317     protected function init_zend_server() {
318         include "Zend/Loader.php";
319         Zend_Loader::registerAutoload();
320         //TODO: set up some server options and debugging too - maybe a new method
321         //TODO: add some zend exeption handler too
322         $this->zend_server = new $this->zend_class();
323     }
325     /**
326      * Send the error information to the WS client.
327      * @param exception $ex
328      * @return void
329      */
330     protected function send_error($ex=null) {
331         var_dump($ex);
332         die('TODO');
333         // TODO: find some way to send the error back through the Zend
334     }
336     /**
337      * Authenticate user using username+password or token.
338      * This function sets up $USER global.
339      * It is safe to use has_capability() after this.
340      * This method also verifies user is allowed to use this
341      * server.
342      * @return void
343      */
344     protected function authenticate_user() {
345         global $CFG, $DB;
347         if (!NO_MOODLE_COOKIES) {
348             throw new coding_exception('Cookies must be disabled in WS servers!');
349         }
351         if ($this->simple) {
352             $this->restricted_context = get_context_instance(CONTEXT_SYSTEM);
354             if (!is_enabled_auth('webservice')) {
355                 throw new webservice_access_exception('WS auth not enabled');
356             }
358             if (!$auth = get_auth_plugin('webservice')) {
359                 throw new webservice_access_exception('WS auth missing');
360             }
362             // the username is hardcoded as URL parameter because we can not easily parse the request data :-(
363             if (!$username = optional_param('wsusername', '', PARAM_RAW)) {
364                 throw new webservice_access_exception('Missing username');
365             }
367             // the password is hardcoded as URL parameter because we can not easily parse the request data :-(
368             if (!$password = optional_param('wspassword', '', PARAM_RAW)) {
369                 throw new webservice_access_exception('Missing password');
370             }
372             if (!$auth->user_login_webservice($username, $password)) {
373                 throw new webservice_access_exception('Wrong username or password');
374             }
376             $user = $DB->get_record('user', array('username'=>$username, 'mnethostid'=>$CFG->mnet_localhost_id, 'deleted'=>0), '*', MUST_EXIST);
378             // now fake user login, the session is completely empty too
379             session_set_user($user);
381         } else {
383             //TODO: not implemented yet
384             die('token login not implemented yet');
385             //TODO: $this->restricted_context is derived from the token context
386         }
388         if (!has_capability("webservice/$this->wsname:use", $this->restricted_context)) {
389             throw new webservice_access_exception('Access to web service not allowed');
390         }
392         external_api::set_context_restriction($this->restricted_context);
393     }
395     /**
396      * Specialised exception handler, we can not use the standard one because
397      * it can not just print html to output.
398      *
399      * @param exception $ex
400      * @return void does not return
401      */
402     public function exception_handler($ex) {
403         global $CFG, $DB, $SCRIPT;
405         // detect active db transactions, rollback and log as error
406         if ($DB->is_transaction_started()) {
407             error_log('Database transaction aborted by exception in ' . $CFG->dirroot . $SCRIPT);
408             try {
409                 // note: transaction blocks should never change current $_SESSION
410                 $DB->rollback_sql();
411             } catch (Exception $ignored) {
412             }
413         }
415         // now let the plugin send the exception to client
416         $this->send_error($ex);
418         // some hacks might need a cleanup hook
419         $this->session_cleanup($ex);
421         // not much else we can do now, add some logging later
422         exit(1);
423     }
425     /**
426      * Future hook needed for emulated sessions.
427      * @param exception $exception null means normal termination, $exception received when WS call failed
428      * @return void
429      */
430     protected function session_cleanup($exception=null) {
431         if ($this->simple) {
432             // nothing needs to be done, there is no persistent session
433         } else {
434             // close emulated session if used
435         }
436     }
441 /**
442  * Web Service server base class, this class handles both
443  * simple and token authentication.
444  * @author Petr Skoda (skodak)
445  */
446 abstract class webservice_base_server implements webservice_server {
448     /** @property string $wsname name of the web server plugin */
449     protected $wsname = null;
451     /** @property bool $simple true if simple auth used */
452     protected $simple;
454     /** @property string $username name of local user */
455     protected $username = null;
457     /** @property string $password password of the local user */
458     protected $password = null;
460     /** @property string $token authentication token*/
461     protected $token = null;
463     /** @property object restricted context */
464     protected $restricted_context;
466     /** @property array $parameters the function parameters - the real values submitted in the request */
467     protected $parameters = null;
469     /** @property string $functionname the name of the function that is executed */
470     protected $functionname = null;
472     /** @property object $function full function description */
473     protected $function = null;
475     /** @property mixed $returns function return value */
476     protected $returns = null;
478     /**
479      * Contructor
480      */
481     public function __construct() {
482     }
484     /**
485      * This method parses the request input, it needs to get:
486      *  1/ user authentication - username+password or token
487      *  2/ function name
488      *  3/ function parameters
489      *
490      * @return void
491      */
492     abstract protected function parse_request();
494     /**
495      * Send the result of function call to the WS client.
496      * @return void
497      */
498     abstract protected function send_response();
500     /**
501      * Send the error information to the WS client.
502      * @param exception $ex
503      * @return void
504      */
505     abstract protected function send_error($ex=null);
508     /**
509      * Process request from client.
510      * @param bool $simple use simple authentication
511      * @return void
512      */
513     public function run($simple) {
514         $this->simple = $simple;
516         // we will probably need a lot of memory in some functions
517         @raise_memory_limit('128M');
519         // set some longer timeout, this script is not sending any output,
520         // this means we need to manually extend the timeout operations
521         // that need longer time to finish
522         external_api::set_timeout();
524         // set up exception handler first, we want to sent them back in correct format that
525         // the other system understands
526         // we do not need to call the original default handler because this ws handler does everything
527         set_exception_handler(array($this, 'exception_handler'));
529         // init all properties from the request data
530         $this->parse_request();
532         // authenticate user, this has to be done after the request parsing
533         // this also sets up $USER and $SESSION
534         $this->authenticate_user();
536         // find all needed function info and make sure user may actually execute the function
537         $this->load_function_info();
539         // finally, execute the function - any errors are catched by the default exception handler
540         $this->execute();
542         // send the results back in correct format
543         $this->send_response();
545         // session cleanup
546         $this->session_cleanup();
548         die;
549     }
551     /**
552      * Specialised exception handler, we can not use the standard one because
553      * it can not just print html to output.
554      *
555      * @param exception $ex
556      * @return void does not return
557      */
558     public function exception_handler($ex) {
559         global $CFG, $DB, $SCRIPT;
561         // detect active db transactions, rollback and log as error
562         if ($DB->is_transaction_started()) {
563             error_log('Database transaction aborted by exception in ' . $CFG->dirroot . $SCRIPT);
564             try {
565                 // note: transaction blocks should never change current $_SESSION
566                 $DB->rollback_sql();
567             } catch (Exception $ignored) {
568             }
569         }
571         // now let the plugin send the exception to client
572         $this->send_error($ex);
574         // some hacks might need a cleanup hook
575         $this->session_cleanup($ex);
577         // not much else we can do now, add some logging later
578         exit(1);
579     }
581     /**
582      * Future hook needed for emulated sessions.
583      * @param exception $exception null means normal termination, $exception received when WS call failed
584      * @return void
585      */
586     protected function session_cleanup($exception=null) {
587         if ($this->simple) {
588             // nothing needs to be done, there is no persistent session
589         } else {
590             // close emulated session if used
591         }
592     }
594     /**
595      * Authenticate user using username+password or token.
596      * This function sets up $USER global.
597      * It is safe to use has_capability() after this.
598      * This method also verifies user is allowed to use this
599      * server.
600      * @return void
601      */
602     protected function authenticate_user() {
603         global $CFG, $DB;
605         if (!NO_MOODLE_COOKIES) {
606             throw new coding_exception('Cookies must be disabled in WS servers!');
607         }
609         if ($this->simple) {
610             $this->restricted_context = get_context_instance(CONTEXT_SYSTEM);
612             if (!is_enabled_auth('webservice')) {
613                 throw new webservice_access_exception('WS auth not enabled');
614             }
616             if (!$auth = get_auth_plugin('webservice')) {
617                 throw new webservice_access_exception('WS auth missing');
618             }
620             if (!$this->username) {
621                 throw new webservice_access_exception('Missing username');
622             }
624             if (!$this->password) {
625                 throw new webservice_access_exception('Missing password');
626             }
628             if (!$auth->user_login_webservice($this->username, $this->password)) {
629                 throw new webservice_access_exception('Wrong username or password');
630             }
632             $user = $DB->get_record('user', array('username'=>$this->username, 'mnethostid'=>$CFG->mnet_localhost_id, 'deleted'=>0), '*', MUST_EXIST);
634             // now fake user login, the session is completely empty too
635             session_set_user($user);
636         } else {
638             //TODO: not implemented yet
639             die('token login not implemented yet');
640             //TODO: $this->restricted_context is derived from the token context
641         }
643         if (!has_capability("webservice/$this->wsname:use", $this->restricted_context)) {
644             throw new webservice_access_exception('Access to web service not allowed');
645         }
647         external_api::set_context_restriction($this->restricted_context);
648     }
650     /**
651      * Fetches the function description from database,
652      * verifies user is allowed to use this function and
653      * loads all paremeters and return descriptions.
654      * @return void
655      */
656     protected function load_function_info() {
657         global $DB, $USER, $CFG;
659         if (empty($this->functionname)) {
660             throw new invalid_parameter_exception('Missing function name');
661         }
663         // function must exist
664         $function = external_function_info($this->functionname);
666         // now let's verify access control
667         if ($this->simple) {
668             // now make sure the function is listed in at least one service user is allowed to use
669             // allow access only if:
670             //  1/ entry in the external_services_users table if required
671             //  2/ validuntil not reached
672             //  3/ has capability if specified in service desc
673             //  4/ iprestriction
675             $sql = "SELECT s.*, NULL AS iprestriction
676                       FROM {external_services} s
677                       JOIN {external_services_functions} sf ON (sf.externalserviceid = s.id AND s.restrictedusers = 0 AND sf.functionname = :name1)
678                      WHERE s.enabled = 1
680                      UNION
682                     SELECT s.*, su.iprestriction
683                       FROM {external_services} s
684                       JOIN {external_services_functions} sf ON (sf.externalserviceid = s.id AND s.restrictedusers = 1 AND sf.functionname = :name2)
685                       JOIN {external_services_users} su ON (su.externalserviceid = s.id AND su.userid = :userid)
686                      WHERE s.enabled = 1 AND su.validuntil IS NULL OR su.validuntil < :now";
687             $params = array('userid'=>$USER->id, 'name1'=>$function->name, 'name2'=>$function->name, 'now'=>time());
688         } else {
690             //TODO: token may restrict access to one service only
691             die('not implemented yet');
692         }
694         $rs = $DB->get_recordset_sql($sql, $params);
695         // now make sure user may access at least one service
696         $remoteaddr = getremoteaddr();
697         $allowed = false;
698         foreach ($rs as $service) {
699             if ($service->requiredcapability and !has_capability($service->requiredcapability, $this->restricted_context)) {
700                 continue; // cap required, sorry
701             }
702             if ($service->iprestriction and !address_in_subnet($remoteaddr, $service->iprestriction)) {
703                 continue; // wrong request source ip, sorry
704             }
705             $allowed = true;
706             break; // one service is enough, no need to continue
707         }
708         $rs->close();
709         if (!$allowed) {
710             throw new invalid_parameter_exception('Access to external function not allowed');
711         }
713         // we have all we need now
714         $this->function = $function;
715     }
717     /**
718      * Execute previously loaded function using parameters parsed from the request data.
719      * @return void
720      */
721     protected function execute() {
722         // validate params, this also sorts the params properly, we need the correct order in the next part
723         $params = call_user_func(array($this->function->classname, 'validate_parameters'), $this->function->parameters_desc, $this->parameters);
725         // execute - yay!
726         $this->returns = call_user_func_array(array($this->function->classname, $this->function->methodname), array_values($params));
727     }