openrat-cms

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

Dispatcher.class.php (20708B)


      1 <?php
      2 
      3 /*
      4  * Loading and calling the action class (the "controller").
      5  */
      6 namespace cms;
      7 
      8 use BadMethodCallException;
      9 use cms\action\Action;
     10 use cms\action\RequestParams;
     11 use cms\action\Response;
     12 use cms\auth\Auth;
     13 use cms\auth\InternalAuth;
     14 use cms\base\Configuration;
     15 use cms\base\DB;
     16 use cms\base\DefaultConfig;
     17 use cms\base\Startup;
     18 use cms\base\Version;
     19 use cms\model\User;
     20 use configuration\Config;
     21 use configuration\ConfigurationLoader;
     22 use database\Database;
     23 use cms\update\Update;
     24 use Exception;
     25 use language\Language;
     26 use language\Messages;
     27 use util\Cookie;
     28 use util\ClassName;
     29 use util\ClassUtils;
     30 use util\exception\DatabaseException;
     31 use util\exception\ObjectNotFoundException;
     32 use util\exception\ValidationException;
     33 use util\FileUtils;
     34 use util\Http;
     35 use logger\Logger;
     36 use LogicException;
     37 use util\exception\UIException;
     38 use util\exception\SecurityException;
     39 use util\json\JSON;
     40 use util\Request;
     41 use util\Session;
     42 use util\Text;
     43 use util\text\TextMessage;
     44 
     45 
     46 /**
     47  * Dispatcher for all cms actions.
     48  *
     49  * @package cms
     50  */
     51 class Dispatcher
     52 {
     53     /**
     54      * @var RequestParams
     55      */
     56     private $request;
     57 
     58 	/**
     59 	 * @var Response
     60 	 */
     61 	private $response;
     62 
     63     /**
     64      * Vollständige Abarbeitug einer Aktion.
     65      * Führt die gesamte Abarbeitung einer Aktion durch, incl. Datenbank-Transaktionssteuerung.
     66      */
     67     public function doAction()
     68     {
     69 		if   ( $this->request->withAuthorization )
     70 			; // No session required (otherwise every API request would create a new session)
     71 		else
     72 			Session::start();
     73 
     74         $this->checkConfiguration();
     75 
     76 		define('PRODUCTION' , Configuration::Conf()->is('production',true));
     77         define('DEVELOPMENT', !PRODUCTION);
     78 
     79         if( DEVELOPMENT)
     80         {
     81             ini_set('display_errors'        , 1);
     82             ini_set('display_startup_errors', 1);
     83             error_reporting(E_ALL);
     84         }else {
     85             ini_set('display_errors'        , 0);
     86             ini_set('display_startup_errors', 0);
     87             error_reporting(0);
     88         }
     89 
     90         $this->setContentLanguageHeader();
     91 
     92         // Nachdem die Konfiguration gelesen wurde, kann nun der Logger benutzt werden.
     93         $this->initializeLogger();
     94 
     95         // Sollte nur 1x pro Sitzung ausgeführt werden. Wie ermitteln wir das?
     96         //if ( DEVELOPMENT )
     97         //    Logger::debug( "Effective configuration:\n".YAML::YAMLDump($conf) );
     98 
     99 		$umask = Configuration::subset('security')->get('umask','');
    100 		if   ( $umask )
    101             umask(octdec($umask));
    102 
    103 		$timeout = Configuration::subset('interface')->get('timeout',0);
    104 		if   ( $timeout )
    105             set_time_limit($timeout);
    106 
    107         $this->checkPostToken();
    108 
    109         $this->connectToDatabase();
    110         $this->startDatabaseTransaction();
    111 		$this->checkLogin();
    112 
    113         try{
    114             $this->callActionMethod();
    115         }
    116         catch(Exception $e)
    117         {
    118             // In case of exception, rolling back the transaction
    119             try
    120             {
    121                 $this->rollbackDatabaseTransaction();
    122             }
    123             catch(Exception $re)
    124             {
    125                 Logger::warn("rollback failed:".$e->getMessage());
    126             }
    127 
    128             throw $e;
    129         }
    130 
    131         $this->writeAuditLog();
    132         $this->commitDatabaseTransaction();
    133 
    134         if  ( DEVELOPMENT )
    135             Logger::trace('Output' . "\n" . print_r( $this->response->getOutputData(),true));
    136 
    137 
    138         // Ablaufzeit für den Inhalt auf aktuelle Zeit setzen.
    139 		if   ( !$this->response->hasHeader('Expires') )
    140         	$this->response->addHeader('Expires', substr(date('r', time() - date('Z')), 0, -5) . 'GMT', false);
    141     }
    142 
    143 
    144 	/**
    145 	 * @param $request RequestParams
    146 	 * @param $response Response
    147 	 */
    148 	public function setRequestAndResponse($request, $response)
    149 	{
    150 		$this->request  = $request;
    151 		$this->response = $response;
    152 	}
    153 
    154 
    155 
    156 	/**
    157 	 * Clear up after the work is done.
    158 	 */
    159 	private function clear() {
    160 		// Yes, closing the session flushes the session data and unlocks other waiting requests.
    161 		// Now another request is able to be executed.
    162 		Session::close();
    163 		if   ( $this->request->authUser ) {
    164 			session_destroy();
    165 			setcookie('or_sid','',time());
    166 		}
    167 	}
    168 
    169 
    170 	/**
    171 	 * Make an authentication, if there is a HTTP authorization.
    172 	 */
    173 	private function checkLogin() {
    174 
    175 		if   ( $this->request->withAuthorization ) {
    176 			$securityConfig = Configuration::subset('security');
    177 			$authConfig = $securityConfig->subset('authorization');
    178 
    179 			if   ( $this->request->authUser ) {
    180 				if   ( ! $authConfig->is('basic'))
    181 					throw new SecurityException('Basic Authorization is disabled');
    182 
    183 				$userAuth = new InternalAuth();
    184 				$status = $userAuth->login( $this->request->authUser,$this->request->authPassword,'' );
    185 				if   ( ! ($status & Auth::STATUS_SUCCESS) )
    186 					throw new SecurityException('user cannot be authenticated');
    187 
    188 				$user = User::loadWithName( $this->request->authUser,User::AUTH_TYPE_INTERNAL );
    189 				Request::setUser( $user );
    190 			}
    191 			elseif   ( $authToken = $this->request->authToken ) {
    192 				if   ( ! $authConfig->is('bearer'))
    193 					throw new SecurityException('Bearer Authorization is disabled');
    194 
    195 				// We are expecting a JSON webtoken here
    196 				function base64url_decode( $data ){
    197 					return base64_decode( strtr( $data, '-_', '+/') . str_repeat('=', 3 - ( 3 + strlen( $data )) % 4 ));
    198 				}
    199 
    200 				list( $b64Header,$b64Payload,$signature ) = array_pad(explode('.',$authToken),3,'');
    201 				$header = JSON::decode( base64url_decode($b64Header) );
    202 				$supportedAlgos = [
    203 					'HS256' => 'sha256',
    204 					'HS384' => 'sha384',
    205 					'HS512' => 'sha512',
    206 				];
    207 				if ( ! $algo = @$supportedAlgos[ @$header['alg'] ] )
    208 					throw new SecurityException('Unknown algorithm in JWT, only supporting: '.implode(',',array_keys($supportedAlgos) ));
    209 				elseif( ! in_array( $algo,hash_algos() ) )
    210 					throw new SecurityException('Unsupported algorithm');
    211 				elseif( hash_hmac( $algo,$b64Header.'.'.$b64Payload,$securityConfig->get('key'),true) != base64url_decode($signature) )
    212 					throw new SecurityException('Signature does not match');
    213 
    214 				$payload = JSON::decode( base64url_decode($b64Payload));
    215 
    216 				if   ( $notBefore = @$payload['nbf'] )
    217 					if   ( $notBefore < Startup::getStartTime() )
    218 						throw new SecurityException('token is not valid');
    219 
    220 				if   ( $expires = @$payload['exp'] )
    221 					if   ( $expires > Startup::getStartTime() )
    222 						throw new SecurityException('token is not valid');
    223 
    224 				if   ( $subject = @$payload['sub'] ) {
    225 					if   ( $user = User::loadWithName( $subject,User::AUTH_TYPE_INTERNAL ) )
    226 						Request::setUser( $user );
    227 					else
    228 						throw new SecurityException('User not found');
    229 				}
    230 				else
    231 					throw new SecurityException('User not found');
    232 			}
    233 		}
    234 	}
    235 
    236 
    237 	/**
    238 	 * checks if the request contains a pleasant CSRF token.
    239 	 *
    240 	 * @return void
    241 	 */
    242     private function checkPostToken()
    243     {
    244 		if ( $this->request->withAuthorization )
    245 			return; // no CSRF token necessary if there are no cookies used for authentication.
    246 
    247         if ( Configuration::subset('security')->is('use_post_token',true) &&
    248 			 $this->request->isAction &&
    249 			 $this->request->getToken() != Session::token() ) {
    250             Logger::warn( TextMessage::create(
    251             	'Token mismatch: Needed ${expected}), but got ${actual} Maybe an attacker?',
    252 				[
    253 					'expected' => Session::token(),
    254 					'actual'   => $this->request->getToken()
    255 				])
    256 			);
    257             throw new SecurityException("Token mismatch");
    258         }
    259     }
    260 
    261     /**
    262      * Logger initialisieren.
    263      */
    264     private function initializeLogger()
    265     {
    266         $logConfig = Configuration::subset('log');
    267 
    268         Logger::$messageFormat = $logConfig->get('format',['time','level','host','text']);
    269 
    270         Logger::$logto = 0; // initially disable all logging endpoints.
    271 
    272 		$logFile = $logConfig->get('file','');
    273         if    ( $logFile ) {
    274         	// Write to a logfile
    275         	if   ( FileUtils::isRelativePath($logFile) )
    276 				$logFile = __DIR__ . '/../../' . $logFile; // prepend relativ path to app root
    277 			Logger::$filename = $logFile;
    278 			Logger::$logto    = Logger::$logto |= Logger::LOG_TO_FILE;
    279 		}
    280         if   ( $logConfig->is('syslog')) // write to syslog
    281 			Logger::$logto    = Logger::$logto |= Logger::LOG_TO_ERROR_LOG;
    282         if   ( $logConfig->is('stdout')) // write to standard out
    283 			Logger::$logto    = Logger::$logto |= Logger::LOG_TO_STDOUT;
    284         if   ( $logConfig->is('stderr')) // write to standard error
    285 			Logger::$logto    = Logger::$logto |= Logger::LOG_TO_STDERR;
    286 
    287         Logger::$dateFormat = $logConfig->get('date_format','r');
    288         Logger::$nsLookup   = $logConfig->is('ns_lookup',false);
    289 
    290 		Logger::$outputType = (int) @constant('\\logger\\Logger::OUTPUT_' . strtoupper($logConfig->get('output','PLAIN')));
    291 		Logger::$level      = (int) @constant('\\logger\\Logger::LEVEL_'  . strtoupper($logConfig->get('level' ,'WARN' )));
    292 
    293         Logger::$messageCallback = function ( $key ) {
    294 
    295         	switch( $key) {
    296 				case 'action':
    297 					return Session::get('action');
    298 
    299 				case 'user':
    300 					$user = Request::getUser();
    301 					if (is_object($user))
    302 						return  $user->name;
    303 					else
    304 						return '';
    305 				default:
    306 					return '';
    307 			}
    308         };
    309 
    310         Logger::init();
    311     }
    312 
    313     private function checkConfiguration()
    314     {
    315         $conf = Request::getConfig();
    316 
    317 		$configFile = getenv( 'CMS_CONFIG_FILE' ) ?: Startup::DEFAULT_CONFIG_FILE;
    318 
    319 		// Konfiguration lesen.
    320 		// Wenn Konfiguration noch nicht in Session vorhanden oder die Konfiguration geändert wurde (erkennbar anhand des Datei-Datums)
    321 		// dann die Konfiguration neu einlesen.
    322 		$configLoader = new ConfigurationLoader( $configFile );
    323 
    324         if (!is_array($conf) || @$conf['config']['auto_reload'] && $configLoader->lastModificationTime() > @$conf['config']['last_modification_time']) {
    325 
    326             // Da die Konfiguration neu eingelesen wird, sollten wir auch die Sitzung komplett leeren.
    327             if (is_array($conf) && $conf['config']['session_destroy_on_config_reload'])
    328                 session_unset();
    329 
    330             // Fest eingebaute Standard-Konfiguration laden.
    331             $conf = DefaultConfig::get();
    332 
    333             $customConfig = $configLoader->load();
    334             $conf = array_replace_recursive($conf, $customConfig);
    335 
    336             // Sprache lesen
    337 			$languages = [];
    338 
    339 			if	( $user = Request::getUser() )
    340 				$languages[] = $user->language; // user language has precedence.
    341 			else {
    342 				$i18nConfig = (new Config($conf))->subset('i18n');
    343 
    344 				$languages[] = $i18nConfig->get('default','en');
    345 
    346 				if	( $i18nConfig->is('use_http',true ) )
    347 					// Die vom Browser angeforderten Sprachen ermitteln
    348 					$languages = array_merge( $languages,Http::getLanguages() );
    349 			}
    350 
    351 			// Default-Sprache hinzufuegen.
    352 			// Wird dann verwendet, wenn die vom Browser angeforderten Sprachen
    353 			// nicht vorhanden sind
    354 			$languages[] = 'en'; // last fallback.
    355 
    356 
    357 			foreach ($languages as $l) {
    358                 if (!in_array($l, Messages::$AVAILABLE_LANGUAGES))
    359                     continue; // language is not available.
    360 
    361                 $language = new Language();
    362                 $lang = $language->getLanguage( $l );
    363                 $conf['language'] = $lang;
    364                 $conf['language']['language_code'] = $l;
    365                 break;
    366             }
    367 
    368 
    369             if (!isset($conf['language']))
    370                 throw new \LogicException('no language found! (languages=' . implode(',', $languages) . ')');
    371 
    372             // Schreibt die Konfiguration in die Sitzung. Diese wird anschliessend nicht
    373             // mehr veraendert.
    374             Request::setConfig($conf);
    375         }
    376 
    377     }
    378 
    379     /**
    380      * Aufruf der Action-Methode.
    381      *
    382      * @return Response Vollständige Rückgabe aller Daten
    383      */
    384     private function callActionMethod()
    385     {
    386     	$action = $this->request->action;
    387     	$method = $this->request->method;
    388 
    389     	while( true ) {
    390 			$actionClassName = new ClassName( ucfirst($action) . ucfirst($method) . 'Action');
    391 			$actionClassName->addNamespace( 'cms\\' . ($this->request->isUIAction ? 'ui\\' : '') . 'action\\' . $action );
    392 
    393 			if ( $actionClassName->exists() )
    394 				break;
    395 
    396 			$baseActionClassName = new ClassName( ucfirst($action) . 'Action' );
    397 			$baseActionClassName->addNamespace( 'cms\\' . ($this->request->isUIAction ? 'ui\\' : '') . 'action' );
    398 
    399 			if ( ! $baseActionClassName->exists() )
    400 				throw new LogicException('Action \''.$action.'\' is not available, class not found: '.$baseActionClassName->get() );
    401 
    402 			if   ( ! $baseActionClassName->getParent()->exists() )
    403 				throw new BadMethodCallException($baseActionClassName->get().' does not exist.');
    404 
    405 			$action = strtolower( $baseActionClassName->dropNamespace()->dropSuffix('Action')->get() );
    406 
    407 			if   ( ! $action ) {
    408 				throw new BadMethodCallException( TextMessage::create( 'No action found for action ${0} and method ${1}',[$this->request->action,$this->request->method] ) );
    409 			}
    410 		}
    411 
    412 
    413         // Erzeugen der Action-Klasse
    414 		$class = $actionClassName->get();
    415         /* @type $do Action */
    416         $do = new $class;
    417 
    418         $do->request         = $this->request;
    419         $do->response        = $this->response;
    420         $do->init();
    421 
    422         $do->checkAccess();
    423 
    424         // POST-Request => ...Post() wird aufgerufen.
    425         // GET-Request  => ...View() wird aufgerufen.
    426         $subactionMethodName = $this->request->isAction ? 'post' :  'view';;
    427 
    428         // Daten werden nur angezeigt, die Sitzung kann also schon geschlossen werden.
    429         // Halt! In Index-Action können Benutzer-Logins gesetzt werden.
    430         if   ( ! $this->request->isAction && $this->request->action != 'index' && $this->request->method != 'oidc' )
    431             Session::close();
    432 
    433         Logger::debug("Dispatcher executing {$action}/{$method}/" . $this->request->getId().' -> '.$actionClassName->get().'#'.$subactionMethodName.'()');
    434 
    435 
    436         try {
    437 			$method = new \ReflectionMethod($do,$subactionMethodName);
    438 			$params = [];
    439 			foreach( $method->getParameters() as $parameter ) {
    440 				$params[ $parameter->getName() ] = $this->request->getRequiredRaw($parameter->getName());
    441 			}
    442 
    443             $method->invokeArgs($do,$params); // <== Executing the Action
    444         }
    445         catch (ValidationException $ve)
    446         {
    447         	// The validation exception is catched here
    448             $do->addValidationError( $ve->fieldName,$ve->key,$ve->params );
    449 
    450             if   ( !$this->request->isAction )
    451             	// Validation exceptions should only be thrown in POST requests.
    452             	throw new BadMethodCallException("Validation error in GET request",0,$ve);
    453         }
    454         catch (\ReflectionException $re)
    455         {
    456             throw new BadMethodCallException("Method '$subactionMethodName' does not exist",0,$re);
    457         }
    458 
    459         // The action is able to change its method name.
    460         $this->request   = $do->request;
    461         $this->request->action = $action;
    462     }
    463 
    464     /**
    465      * Startet die Verbindung zur Datenbank.
    466      */
    467     private function connectToDatabase()
    468     {
    469 		$allDbConfig = Configuration::subset('database');
    470 
    471 		// Filter all enabled databases
    472 		$enabledDatabases = array_filter($allDbConfig->subsets(), function ($dbConfig) {
    473 			return $dbConfig->is('enabled',true);
    474 		});
    475 
    476 		$enabledDbids     = array_keys( $enabledDatabases );
    477 
    478 		if   ( ! $enabledDbids )
    479 			throw new UIException(Messages::DATABASE_CONNECTION_ERROR, 'No database configured.', [], new DatabaseException('No database configured'));
    480 
    481         $possibleDbIds = [];
    482 
    483         if   ( $databaseId = $this->request->getDatabaseId() )
    484             $possibleDbIds[] = $databaseId;
    485 
    486         if   ( $dbId = Request::getDatabaseId() )
    487             $possibleDbIds[] = $dbId;
    488 
    489         if   ( Cookie::has(Action::COOKIE_DB_ID) )
    490             $possibleDbIds[] = Cookie::get(Action::COOKIE_DB_ID);
    491 
    492 		$possibleDbIds[] = Configuration::subset('database-default')->get('default-id' );
    493 
    494 		$possibleDbIds[] = $enabledDbids[0];
    495 
    496 		foreach( $possibleDbIds as $dbId ) {
    497 			if	( in_array($dbId,$enabledDbids) ) {
    498 
    499 				$dbConfig = $allDbConfig->subset( $dbId );
    500 
    501 				try
    502 				{
    503 					$key = $this->request->isAction && !Startup::readonly() ?'write':'read';
    504 
    505 					$db = new Database( $dbConfig->merge( $dbConfig->subset($key))->getConfig() );
    506 					$db->id = $dbId;
    507 
    508 				}
    509 				catch(\Exception $e) {
    510 					throw new UIException(Messages::DATABASE_CONNECTION_ERROR, "Could not connect to DB " . $dbId, [], $e);
    511 				}
    512 
    513 				// Is this the first time we are connected to this database in this session?
    514 				$firstDbContact = Request::getDatabaseId() != $dbId;
    515 
    516 				Request::setDatabase  ( $db   );
    517 
    518 				if   ( $firstDbContact )
    519 					// Test, if we must install/update the database scheme.
    520 					$this->updateDatabase( $dbId );
    521 
    522 				return;
    523 			}
    524 		}
    525 
    526 		throw new LogicException('Unreachable code'); // at least the first db connection should be found
    527     }
    528 
    529 
    530 
    531     /**
    532      * Updating the database.
    533      *
    534      * @param $dbid integer
    535      * @throws UIException
    536      */
    537     private function updateDatabase($dbid)
    538     {
    539         $dbConfig = Configuration::Conf()->subset('database')->subset($dbid);
    540 
    541         if   ( ! $dbConfig->is('check_version',true))
    542             return; // Check for DB version is disabled.
    543 
    544         $updater = new Update();
    545 
    546         if   ( ! $updater->isUpdateRequired( DB::get() ) )
    547             return;
    548 
    549 		Logger::error("Database update required. Please call /status/?upgrade");
    550 		throw new LogicException("Database update required, try calling /status/?upgrade");
    551     }
    552 
    553 
    554 
    555 
    556     /**
    557      * Eröffnet eine Transaktion.
    558      */
    559     private function startDatabaseTransaction()
    560     {
    561         // Verbindung zur Datenbank
    562         //
    563         $db = Request::getDatabase();
    564 
    565         if (is_object($db)) {
    566             // Transactions are only needed for POST-Request
    567             // GET-Request do only read from the database and have no need for transactions.
    568             if  ( $this->request->isAction )
    569             {
    570                 $db->start();
    571 
    572                 //register_shutdown_function( function() {
    573                 //        $this->rollbackDatabaseTransaction();
    574                 //});
    575             }
    576         }
    577 
    578     }
    579 
    580 
    581     private function commitDatabaseTransaction()
    582     {
    583         $db = Request::getDatabase();
    584 
    585         if (is_object($db))
    586             // Transactions were only started for POST-Request
    587             if($this->request->isAction)
    588                 $db->commit();
    589     }
    590 
    591 
    592 
    593     private function rollbackDatabaseTransaction()
    594     {
    595         $db = Request::getDatabase();
    596 
    597         if (is_object($db))
    598             // Transactions were only started for POST-Request
    599             if($this->request->isAction)
    600                 $db->rollback();
    601     }
    602 
    603 
    604     /**
    605      * Sets the "Content-Language"-HTTP-Header with the user language.
    606      */
    607     private function setContentLanguageHeader()
    608     {
    609         header('Content-Language: ' . Configuration::Conf()->subset('language')->get('language_code') );
    610     }
    611 
    612 
    613 
    614     private function writeAuditLog()
    615     {
    616         // Only write Audit Log for POST requests.
    617         if   ( ! $this->request->isAction )
    618             return;
    619 
    620         $auditConfig = Configuration::subset('audit-log');
    621 
    622         if   ( $auditConfig->is('enabled',false))
    623         {
    624             $dir = $auditConfig->get('directory','./audit-log' );
    625 
    626             if   ( $dir[0] != '/' )
    627                 $dir = __DIR__ . '/../../' .$dir;
    628 
    629             $micro_date = microtime();
    630             $date = explode(" ",$micro_date);
    631             $filename = $dir.'/'.$auditConfig->get('prefix','audit' ).'-'.date('c',$date[1]).'-'.$date[0].'.json';
    632 
    633             $user = Request::getUser();
    634 
    635             $data = array(
    636                 'database'    => array(
    637                     'id'      => DB::get()->id ),
    638                 'user'        => array(
    639                     'id'      => @$user->userid,
    640                     'name'    => @$user->name ),
    641                 'timestamp'   => date('c'),
    642                 'action'      => $this->request->action,
    643                 'method'      => $this->request->method,
    644                 'remote-ip'   => $_SERVER['REMOTE_ADDR'],
    645                 'request-time'=> $_SERVER['REQUEST_TIME'],
    646                 'data'        => $this->filterCredentials( $_REQUEST )
    647             );
    648 
    649             // Write the file.
    650             if   ( file_put_contents( $filename, JSON::encode($data) ) === FALSE )
    651                 Logger::warn('Could not write audit log to file: '.$filename);
    652             else
    653                 Logger::debug('Audit logfile: '.$filename);
    654         }
    655 
    656     }
    657 
    658 
    659     /*
    660      * Filter credentials from an array.
    661      */
    662     private function filterCredentials( $input )
    663     {
    664         foreach( array( 'login_password','password1','password2' ) as $cr )
    665             if  ( isset($input[$cr]))
    666                 $input[$cr] = '***';
    667 
    668         return $input;
    669     }
    670 }