1<?php
2/**
3 * EGroupware API: session handling
4 *
5 * This class is based on the old phpgwapi/inc/class.sessions(_php4).inc.php:
6 * (c) 1998-2000 NetUSE AG Boris Erdmann, Kristian Koehntopp
7 * (c) 2003 FreeSoftware Foundation
8 * Not sure how much the current code still has to do with it.
9 *
10 * Former authers were:
11 * - NetUSE AG Boris Erdmann, Kristian Koehntopp
12 * - Dan Kuykendall <seek3r@phpgroupware.org>
13 * - Joseph Engo <jengo@phpgroupware.org>
14 *
15 * @link http://www.egroupware.org
16 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
17 * @package api
18 * @subpackage session
19 * @author Ralf Becker <ralfbecker@outdoor-training.de> since 2003 on
20 */
21
22namespace EGroupware\Api;
23
24use PragmaRX\Google2FA;
25use EGroupware\Api\Mail\Credentials;
26use EGroupware\OpenID;
27use League\OAuth2\Server\Exception\OAuthServerException;
28
29/**
30 * Create, verifies or destroys an EGroupware session
31 *
32 * If you want to analyse the memory usage in the session, you can uncomment the following call:
33 *
34 * 	static function encrypt($kp3)
35 *	{
36 *		// switch that on to analyse memory usage in the session
37 *		//self::log_session_usage($_SESSION[self::EGW_APPSESSION_VAR],'_SESSION['.self::EGW_APPSESSION_VAR.']',true,5000);
38 */
39class Session
40{
41	/**
42	 * Write debug messages about session verification and creation to the error_log
43	 *
44	 * This will contain passwords! Don't leave it permanently switched on!
45	 */
46	const ERROR_LOG_DEBUG = false;
47
48	/**
49	 * key of eGW's session-data in $_SESSION
50	 */
51	const EGW_SESSION_VAR = 'egw_session';
52
53	/**
54	 * key of eGW's application session-data in $_SESSION
55	 */
56	const EGW_APPSESSION_VAR = 'egw_app_session';
57
58	/**
59	 * key of eGW's required files in $_SESSION
60	 *
61	 * These files get set by Db and Egw class, for classes which get not autoloaded (eg. ADOdb, idots_framework)
62	 */
63	const EGW_REQUIRED_FILES = 'egw_required_files';
64
65	/**
66	 * key of  eGW's egw_info cached in $_SESSION
67	 */
68	const EGW_INFO_CACHE = 'egw_info_cache';
69
70	/**
71	 * key of  eGW's egw object cached in $_SESSION
72	 */
73	const EGW_OBJECT_CACHE = 'egw_object_cache';
74
75	/**
76	 * Name of cookie or get-parameter with session-id
77	 */
78	const EGW_SESSION_NAME = 'sessionid';
79
80	/**
81	 * Name of cookie with remember me token
82	 */
83	const REMEMBER_ME_COOKIE = 'eGW_remember';
84
85	/**
86	* current user login (account_lid@domain)
87	*
88	* @var string
89	*/
90	var $login;
91
92	/**
93	* current user password
94	*
95	* @var string
96	*/
97	var $passwd;
98
99	/**
100	* current user db/ldap account id
101	*
102	* @var int
103	*/
104	var $account_id;
105
106	/**
107	* current user account login id (without the eGW-domain/-instance part
108	*
109	* @var string
110	*/
111	var $account_lid;
112
113	/**
114	* domain for current user
115	*
116	* @var string
117	*/
118	var $account_domain;
119
120	/**
121	* type flag, A - anonymous session, N - None, normal session
122	*
123	* @var string
124	*/
125	var $session_flags;
126
127	/**
128	* current user session id
129	*
130	* @var string
131	*/
132	var $sessionid;
133
134	/**
135	* an other session specific id (md5 from a random string),
136	* used together with the sessionid for xmlrpc basic auth and the encryption of session-data (if that's enabled)
137	*
138	* @var string
139	*/
140	var $kp3;
141
142	/**
143	 * Primary key of egw_access_log row for updates
144	 *
145	 * @var int
146	 */
147	var $sessionid_access_log;
148
149	/**
150	* name of XML-RPC/SOAP method called
151	*
152	* @var string
153	*/
154	var $xmlrpc_method_called;
155
156	/**
157	* Array with the name of the system domains
158	*
159	* @var array
160	*/
161	private $egw_domains;
162
163	/**
164	 * $_SESSION at the time the constructor was called
165	 *
166	 * @var array
167	 */
168	var $required_files;
169
170	/**
171	 * Nummeric code why session creation failed
172	 *
173	 * @var int
174	 */
175	var $cd_reason;
176	const CD_BAD_LOGIN_OR_PASSWORD = 5;
177	const CD_SECOND_FACTOR_REQUIRED = 96;
178	const CD_FORCE_PASSWORD_CHANGE = 97;
179	const CD_ACCOUNT_EXPIRED = 98;
180	const CD_BLOCKED = 99;	// to many failed attempts to loing
181
182	/**
183	 * Verbose reason why session creation failed
184	 *
185	 * @var string
186	 */
187	var $reason;
188
189	/**
190	 * Session action set by update_dla or set_action and stored in __destruct
191	 *
192	 * @var string
193	 */
194	protected $action;
195
196	/**
197	 * Constructor just loads up some defaults from cookies
198	 *
199	 * @param array $domain_names =null domain-names used in this install
200	 */
201	function __construct(array $domain_names=null)
202	{
203		$this->required_files = $_SESSION[self::EGW_REQUIRED_FILES];
204
205		$this->sessionid = self::get_sessionid();
206		$this->kp3       = self::get_request('kp3');
207
208		$this->egw_domains = $domain_names;
209
210		if (!isset($GLOBALS['egw_setup']))
211		{
212			// verfiy and if necessary create and save our config settings
213			//
214			$save_rep = false;
215			if (!isset($GLOBALS['egw_info']['server']['max_access_log_age']))
216			{
217				$GLOBALS['egw_info']['server']['max_access_log_age'] = 90;	// default 90 days
218				$save_rep = true;
219			}
220			if (!isset($GLOBALS['egw_info']['server']['block_time']))
221			{
222				$GLOBALS['egw_info']['server']['block_time'] = 1;	// default 1min, its enough to slow down brute force attacks
223				$save_rep = true;
224			}
225			if (!isset($GLOBALS['egw_info']['server']['num_unsuccessful_id']))
226			{
227				$GLOBALS['egw_info']['server']['num_unsuccessful_id']  = 3;	// default 3 trys per id
228				$save_rep = true;
229			}
230			if (!isset($GLOBALS['egw_info']['server']['num_unsuccessful_ip']))
231			{
232				$GLOBALS['egw_info']['server']['num_unsuccessful_ip']  = $GLOBALS['egw_info']['server']['num_unsuccessful_id'] * 5;	// default is 5 times as high as the id default; since accessing via proxy is quite common
233				$save_rep = true;
234			}
235			if (!isset($GLOBALS['egw_info']['server']['install_id']))
236			{
237				$GLOBALS['egw_info']['server']['install_id']  = md5(Auth::randomstring(15));
238			}
239			if (!isset($GLOBALS['egw_info']['server']['max_history']))
240			{
241				$GLOBALS['egw_info']['server']['max_history'] = 20;
242				$save_rep = true;
243			}
244
245			if ($save_rep)
246			{
247				$config = new Config('phpgwapi');
248				$config->read_repository();
249				$config->value('max_access_log_age',$GLOBALS['egw_info']['server']['max_access_log_age']);
250				$config->value('block_time',$GLOBALS['egw_info']['server']['block_time']);
251				$config->value('num_unsuccessful_id',$GLOBALS['egw_info']['server']['num_unsuccessful_id']);
252				$config->value('num_unsuccessful_ip',$GLOBALS['egw_info']['server']['num_unsuccessful_ip']);
253				$config->value('install_id',$GLOBALS['egw_info']['server']['install_id']);
254				$config->value('max_history',$GLOBALS['egw_info']['server']['max_history']);
255				try
256				{
257					$config->save_repository();
258				}
259				catch (Db\Exception $e) {
260					_egw_log_exception($e);	// ignore exception, as it blocks session creation, if database is not writable
261				}
262			}
263		}
264		self::set_cookiedomain();
265
266		// set session_timeout from global php.ini and default to 14400=4h, if not set
267		if (!($GLOBALS['egw_info']['server']['sessions_timeout'] = ini_get('session.gc_maxlifetime')))
268      	{
269      		ini_set('session.gc_maxlifetime', $GLOBALS['egw_info']['server']['sessions_timeout']=14400);
270      	}
271	}
272
273	/**
274	 * Magic function called when this class get's restored from the session
275	 *
276	 */
277	function __wakeup()
278	{
279		if (!empty($GLOBALS['egw_info']['server']['sessions_timeout']) && session_status() === PHP_SESSION_NONE)
280		{
281			ini_set('session.gc_maxlifetime', $GLOBALS['egw_info']['server']['sessions_timeout']);
282		}
283		$this->action = null;
284	}
285
286	/**
287	 * Destructor: update access-log and encrypt session
288	 */
289	function __destruct()
290	{
291		self::encrypt($this->kp3);
292	}
293
294	/**
295	 * commit the sessiondata to storage
296	 *
297	 * It's necessary to use this function instead of session_write_close() direct, as otherwise the session is not encrypted!
298	 */
299	function commit_session()
300	{
301		if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."() sessionid=$this->sessionid, _SESSION[".self::EGW_SESSION_VAR.']='.array2string($_SESSION[self::EGW_SESSION_VAR]).' '.function_backtrace());
302		self::encrypt($this->kp3);
303
304		session_write_close();
305	}
306
307	/**
308	 * Keys of session variables which get encrypted
309	 *
310	 * @var array
311	 */
312	static $egw_session_vars = array(
313		//self::EGW_SESSION_VAR, no need to encrypt and required by the session list
314		self::EGW_APPSESSION_VAR,
315		self::EGW_INFO_CACHE,
316		self::EGW_OBJECT_CACHE,
317	);
318
319	static $mcrypt;
320
321	/**
322	 * Name of flag in session to signal it is encrypted or not
323	 */
324	const EGW_SESSION_ENCRYPTED = 'egw_session_encrypted';
325
326	/**
327	 * Encrypt the variables in the session
328	 *
329	 * Is called by self::__destruct().
330	 */
331	static function encrypt($kp3)
332	{
333		// switch that on to analyse memory usage in the session
334		//self::log_session_usage($_SESSION[self::EGW_APPSESSION_VAR],'_SESSION['.self::EGW_APPSESSION_VAR.']',true,5000);
335
336		if (!isset($_SESSION[self::EGW_SESSION_ENCRYPTED]) && self::init_crypt($kp3))
337		{
338			foreach(self::$egw_session_vars as $name)
339			{
340				if (isset($_SESSION[$name]))
341				{
342					$_SESSION[$name] = mcrypt_generic(self::$mcrypt,serialize($_SESSION[$name]));
343					//error_log(__METHOD__."() 'encrypting' session var: $name, len=".strlen($_SESSION[$name]));
344				}
345			}
346			$_SESSION[self::EGW_SESSION_ENCRYPTED] = true;	// flag session as encrypted
347
348			mcrypt_generic_deinit(self::$mcrypt);
349			self::$mcrypt = null;
350		}
351	}
352
353	/**
354	 * Log the usage of session-vars
355	 *
356	 * @param array &$arr
357	 * @param string $label
358	 * @param boolean $recursion =true if true call itself for every item > $limit
359	 * @param int $limit =1000 log only differences > $limit
360	 */
361	static function log_session_usage(&$arr,$label,$recursion=true,$limit=1000)
362	{
363		if (!is_array($arr)) return;
364
365		$sizes = array();
366		foreach($arr as $key => &$data)
367		{
368			$sizes[$key] = strlen(serialize($data));
369		}
370		arsort($sizes,SORT_NUMERIC);
371		foreach($sizes as $key => $size)
372		{
373			$diff = $size - (int)$_SESSION[$label.'-sizes'][$key];
374			$_SESSION[$label.'-sizes'][$key] = $size;
375			if ($diff > $limit)
376			{
377				error_log("strlen({$label}[$key])=".Vfs::hsize($size).", diff=".Vfs::hsize($diff));
378				if ($recursion) self::log_session_usage($arr[$key],$label.'['.$key.']',$recursion,$limit);
379			}
380		}
381	}
382
383	/**
384	 * Decrypt the variables in the session
385	 *
386	 * Is called by self::init_handler from api/src/loader.php (called from the header.inc.php)
387	 * before the restore of the eGW enviroment takes place, so that the whole thing can be encrypted
388	 */
389	static function decrypt()
390	{
391		if ($_SESSION[self::EGW_SESSION_ENCRYPTED] && self::init_crypt(self::get_request('kp3')))
392		{
393			foreach(self::$egw_session_vars as $name)
394			{
395				if (isset($_SESSION[$name]))
396				{
397					$_SESSION[$name] = unserialize(trim(mdecrypt_generic(self::$mcrypt,$_SESSION[$name])));
398					//error_log(__METHOD__."() 'decrypting' session var $name: gettype($name) = ".gettype($_SESSION[$name]));
399				}
400			}
401			unset($_SESSION[self::EGW_SESSION_ENCRYPTED]);	// delete encryption flag
402		}
403	}
404
405	/**
406	 * Check if session encryption is configured, possible and initialise it
407	 *
408	 * If mcrypt extension is not available (eg. in PHP 7.2+ no longer contains it) fail gracefully.
409	 *
410	 * @param string $kp3 mcrypt key transported via cookie or get parameter like the session id,
411	 *	unlike the session id it's not know on the server, so only the client-request can decrypt the session!
412	 * @return boolean true if encryption is used, false otherwise
413	 */
414	static private function init_crypt($kp3)
415	{
416		if(!$GLOBALS['egw_info']['server']['mcrypt_enabled'])
417		{
418			return false;	// session encryption is switched off
419		}
420		if ($GLOBALS['egw_info']['currentapp'] == 'syncml' || !$kp3)
421		{
422			$kp3 = 'staticsyncmlkp3';	// syncml has no kp3!
423		}
424		if (is_null(self::$mcrypt))
425		{
426			if (!check_load_extension('mcrypt'))
427			{
428				error_log(__METHOD__."() required PHP extension mcrypt not loaded and can not be loaded, sessions get NOT encrypted!");
429				return false;
430			}
431			if (!(self::$mcrypt = mcrypt_module_open(MCRYPT_TRIPLEDES, '', MCRYPT_MODE_ECB, '')))
432			{
433				error_log(__METHOD__."() could not mcrypt_module_open(MCRYPT_TRIPLEDES,'',MCRYPT_MODE_ECB,''), sessions get NOT encrypted!");
434				return false;
435			}
436			$iv_size = mcrypt_enc_get_iv_size(self::$mcrypt);
437			$iv = !isset($GLOBALS['egw_info']['server']['mcrypt_iv']) || strlen($GLOBALS['egw_info']['server']['mcrypt_iv']) < $iv_size ?
438				mcrypt_create_iv ($iv_size, MCRYPT_RAND) : substr($GLOBALS['egw_info']['server']['mcrypt_iv'],0,$iv_size);
439
440			if (mcrypt_generic_init(self::$mcrypt,$kp3, $iv) < 0)
441			{
442				error_log(__METHOD__."() could not initialise mcrypt, sessions get NOT encrypted!");
443				return self::$mcrypt = false;
444			}
445		}
446		return is_resource(self::$mcrypt);
447	}
448
449	/**
450	 * Create a new eGW session
451	 *
452	 * @param string $login user login
453	 * @param string $passwd user password
454	 * @param string $passwd_type type of password being used, ie plaintext, md5, sha1
455	 * @param boolean $no_session =false dont create a real session, eg. for GroupDAV clients using only basic auth, no cookie support
456	 * @param boolean $auth_check =true if false, the user is loged in without checking his password (eg. for single sign on), default = true
457	 * @param boolean $fail_on_forced_password_change =false true: do NOT create session, if password change requested
458	 * @param string|boolean $check_2fa =false string: 2fa-code to check (only if exists) and fail if wrong, false: do NOT check 2fa
459	 * @param string $remember_me =null "True" for checkbox checked, or periode for user-choice select-box eg. "P1W" or "" for NOT remember
460	 * @return string|boolean session id or false if session was not created, $this->(cd_)reason contains cause
461	 */
462	function create($login,$passwd = '',$passwd_type = '',$no_session=false,$auth_check=true,$fail_on_forced_password_change=false,$check_2fa=false,$remember_me=null)
463	{
464		try {
465			if (is_array($login))
466			{
467				$this->login       = $login['login'];
468				$this->passwd      = $login['passwd'];
469				$this->passwd_type = $login['passwd_type'];
470				$login             = $this->login;
471			}
472			else
473			{
474				$this->login       = $login;
475				$this->passwd      = $passwd;
476				$this->passwd_type = $passwd_type;
477			}
478			if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check) starting ...");
479
480			self::split_login_domain($login,$this->account_lid,$this->account_domain);
481			// add domain to the login, if not already there
482			if (substr($this->login,-strlen($this->account_domain)-1) != '@'.$this->account_domain)
483			{
484				$this->login .= '@'.$this->account_domain;
485			}
486			$now = time();
487			//error_log(__METHOD__."($login,$passwd,$passwd_type,$no_session,$auth_check) account_lid=$this->account_lid, account_domain=$this->account_domain, default_domain={$GLOBALS['egw_info']['server']['default_domain']}, user/domain={$GLOBALS['egw_info']['user']['domain']}");
488
489			// This is to ensure that we authenticate to the correct domain (might not be default)
490			// if no domain is given we use the default domain, so we dont need to re-create everything
491			if (!$GLOBALS['egw_info']['user']['domain'] && $this->account_domain == $GLOBALS['egw_info']['server']['default_domain'])
492			{
493				$GLOBALS['egw_info']['user']['domain'] = $this->account_domain;
494			}
495			elseif (!$this->account_domain && $GLOBALS['egw_info']['user']['domain'])
496			{
497				$this->account_domain = $GLOBALS['egw_info']['user']['domain'];
498			}
499			elseif($this->account_domain != $GLOBALS['egw_info']['user']['domain'])
500			{
501				throw new Exception("Wrong domain! '$this->account_domain' != '{$GLOBALS['egw_info']['user']['domain']}'");
502			}
503			unset($GLOBALS['egw_info']['server']['default_domain']); // we kill this for security reasons
504
505			$user_ip = self::getuser_ip();
506
507			$this->account_id = $GLOBALS['egw']->accounts->name2id($this->account_lid,'account_lid','u');
508
509			// do we need to check 'remember me' token (to bypass authentication)
510			if ($auth_check && !empty($_COOKIE[self::REMEMBER_ME_COOKIE]))
511			{
512				$auth_check = !$this->skipPasswordAuth($_COOKIE[self::REMEMBER_ME_COOKIE], $this->account_id);
513			}
514
515			if (($blocked = $this->login_blocked($login,$user_ip)) ||	// too many unsuccessful attempts
516				$GLOBALS['egw_info']['server']['global_denied_users'][$this->account_lid] ||
517				$auth_check && !$GLOBALS['egw']->auth->authenticate($this->account_lid, $this->passwd, $this->passwd_type) ||
518				$this->account_id && $GLOBALS['egw']->accounts->get_type($this->account_id) == 'g')
519			{
520				$this->reason = $blocked ? 'blocked, too many attempts' : 'bad login or password';
521				$this->cd_reason = $blocked ? self::CD_BLOCKED : self::CD_BAD_LOGIN_OR_PASSWORD;
522
523				// we dont log anon users as it would block the website
524				if (!$GLOBALS['egw']->acl->get_specific_rights_for_account($this->account_id,'anonymous','phpgwapi'))
525				{
526					$this->log_access($this->reason,$login,$user_ip,0);	// log unsuccessfull login
527				}
528				if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check) UNSUCCESSFULL ($this->reason)");
529				return false;
530			}
531
532			if (!$this->account_id && $GLOBALS['egw_info']['server']['auto_create_acct'])
533			{
534				if ($GLOBALS['egw_info']['server']['auto_create_acct'] == 'lowercase')
535				{
536					$this->account_lid = strtolower($this->account_lid);
537				}
538				$this->account_id = $GLOBALS['egw']->accounts->auto_add($this->account_lid, $passwd);
539			}
540			// fix maybe wrong case in username, it makes problems eg. in filemanager (name of homedir)
541			if ($this->account_lid != ($lid = $GLOBALS['egw']->accounts->id2name($this->account_id)))
542			{
543				$this->account_lid = $lid;
544				$this->login = $lid.substr($this->login,strlen($lid));
545			}
546
547			$GLOBALS['egw_info']['user']['account_id'] = $this->account_id;
548
549			// for *DAV and eSync we use a pseudo sessionid created from md5(user:passwd)
550			// --> allows this stateless protocolls which use basic auth to use sessions!
551			if (($this->sessionid = self::get_sessionid(true)))
552			{
553				if (session_status() !== PHP_SESSION_ACTIVE)	// gives warning including password
554				{
555					session_id($this->sessionid);
556				}
557			}
558			elseif (!headers_sent())	// only gives warnings, nothing we can do
559			{
560				self::cache_control();
561				session_start();
562				// set a new session-id, if not syncml (already done in Horde code and can NOT be changed)
563				if (!$no_session && $GLOBALS['egw_info']['flags']['currentapp'] != 'syncml')
564				{
565					session_regenerate_id(true);
566				}
567				$this->sessionid = session_id();
568			}
569			else
570			{
571				$this->sessionid = session_id() ?: Auth::randomstring(24);
572			}
573			$this->kp3       = Auth::randomstring(24);
574
575			$GLOBALS['egw_info']['user'] = $this->read_repositories();
576			if ($GLOBALS['egw']->accounts->is_expired($GLOBALS['egw_info']['user']))
577			{
578				$this->reason = 'account is expired';
579				$this->cd_reason = self::CD_ACCOUNT_EXPIRED;
580
581				if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check) UNSUCCESSFULL ($this->reason)");
582				return false;
583			}
584
585			Cache::setSession('phpgwapi', 'password', base64_encode($this->passwd));
586
587			// if we have a second factor, check it before forced password change
588			if ($check_2fa !== false)
589			{
590				try {
591					$this->checkMultifactorAuth($check_2fa, $_COOKIE[self::REMEMBER_ME_COOKIE]);
592				}
593				catch(\Exception $e) {
594					$this->cd_reason = $e->getCode();
595					$this->reason = $e->getMessage();
596					$this->log_access($this->reason, $login, $user_ip, 0);	// log unsuccessfull login
597					if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check,$fail_on_forced_password_change,'$check_2fa') UNSUCCESSFULL ($this->reason)");
598					return false;
599				}
600			}
601
602			if ($fail_on_forced_password_change && Auth::check_password_change($this->reason) === false)
603			{
604				$this->cd_reason = self::CD_FORCE_PASSWORD_CHANGE;
605				return false;
606			}
607
608			if ($GLOBALS['egw']->acl->check('anonymous',1,'phpgwapi'))
609			{
610				$this->session_flags = 'A';
611			}
612			else
613			{
614				$this->session_flags = 'N';
615			}
616
617			if (($hook_result = Hooks::process(array(
618				'location'       => 'session_creation',
619				'sessionid'      => $this->sessionid,
620				'session_flags'  => $this->session_flags,
621				'account_id'     => $this->account_id,
622				'account_lid'    => $this->account_lid,
623				'passwd'         => $this->passwd,
624				'account_domain' => $this->account_domain,
625				'user_ip'        => $user_ip,
626			),'',true)))	// true = run hooks from all apps, not just the ones the current user has perms to run
627			{
628				foreach($hook_result as $reason)
629				{
630					if ($reason)	// called hook requests to deny the session
631					{
632						$this->reason = $this->cd_reason = $reason;
633						$this->log_access($this->reason,$login,$user_ip,0);		// log unsuccessfull login
634						if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check) UNSUCCESSFULL ($this->reason)");
635						return false;
636					}
637				}
638			}
639			$GLOBALS['egw']->db->transaction_begin();
640			$this->register_session($this->login,$user_ip,$now,$this->session_flags);
641			if ($this->session_flags != 'A')		// dont log anonymous sessions
642			{
643				$this->sessionid_access_log = $this->log_access($this->sessionid,$login,$user_ip,$this->account_id);
644				// We do NOT log anonymous sessions to not block website and also to cope with
645				// high rate anon endpoints might be called creating a bottleneck in the egw_accounts table.
646				Cache::setSession('phpgwapi', 'account_previous_login', $GLOBALS['egw']->auth->previous_login);
647				$GLOBALS['egw']->accounts->update_lastlogin($this->account_id,$user_ip);
648			}
649			$GLOBALS['egw']->db->transaction_commit();
650
651			if (!headers_sent())
652			{
653				if ($GLOBALS['egw_info']['server']['usecookies'] && !$no_session)
654				{
655					self::egw_setcookie(self::EGW_SESSION_NAME, $this->sessionid);
656					self::egw_setcookie('kp3', $this->kp3);
657					self::egw_setcookie('domain', $this->account_domain);
658				}
659				if ($GLOBALS['egw_info']['server']['usecookies'] && !$no_session || isset($_COOKIE['last_loginid']))
660				{
661					self::egw_setcookie('last_loginid', $this->account_lid, $now + 1209600); /* For 2 weeks */
662					self::egw_setcookie('last_domain', $this->account_domain, $now + 1209600);
663				}
664
665				// set new remember me token/cookie, if requested and necessary
666				$expiration = null;
667				if (($token = $this->checkSetRememberMeToken($remember_me, $_COOKIE[self::REMEMBER_ME_COOKIE], $expiration)))
668				{
669					self::egw_setcookie(self::REMEMBER_ME_COOKIE, $token, $expiration);
670				}
671
672				if (self::ERROR_LOG_DEBUG) error_log(__METHOD__ . "($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check) successfull sessionid=$this->sessionid");
673			}
674			elseif (self::ERROR_LOG_DEBUG) error_log(__METHOD__ . "($this->login,$this->passwd,$this->passwd_type,$no_session,$auth_check) could NOT set session cookies, headers already sent");
675
676			// hook called once session is created
677			Hooks::process(array(
678				'location'       => 'session_created',
679				'sessionid'      => $this->sessionid,
680				'session_flags'  => $this->session_flags,
681				'account_id'     => $this->account_id,
682				'account_lid'    => $this->account_lid,
683				'passwd'         => $this->passwd,
684				'account_domain' => $this->account_domain,
685				'user_ip'        => $user_ip,
686				'session_type'   => Session\Type::get($_SERVER['REQUEST_URI'],
687					$GLOBALS['egw_info']['flags']['current_app'],
688					true),	// true return WebGUI instead of login, as we are logged in now
689			),'',true);
690
691			return $this->sessionid;
692		}
693		// catch all exceptions, as their (allways logged) trace (eg. on a database error) would contain the user password
694		catch(Exception $e) {
695			$this->reason = $this->cd_reason = is_a($e, Db\Exception::class) ?
696				// do not output specific database error, eg. invalid SQL statement
697				lang('Database Error!') : $e->getMessage();
698			error_log(__METHOD__."('$login', ".array2string(str_repeat('*', strlen($passwd))).
699				", '$passwd_type', no_session=".array2string($no_session).
700				", auth_check=".array2string($auth_check).
701				", fail_on_forced_password_change=".array2string($fail_on_forced_password_change).
702				") Exception ".$e->getMessage());
703			return false;
704		}
705	}
706
707	/**
708	 * Check if password authentication is required or given token is sufficient
709	 *
710	 * Token is only checked for 'remember_me_token' === 'always', not for default of only for 2FA!
711	 *
712	 * Password auth is also required if 2FA is not disabled and either required or configured by user.
713	 *
714	 * @param string $token value of token
715	 * @param int& $account_id =null account_id of token-owner to limit check on that user, on return account_id of token owner
716	 * @return boolean false: if further auth check is required, true: if token is sufficient for authentication
717	 */
718	public function skipPasswordAuth($token, &$account_id=null)
719	{
720		// if token is empty or disabled --> password authentication required
721		if (empty($token) || $GLOBALS['egw_info']['server']['remember_me_token'] !== 'always' ||
722			!($client = $this->checkOpenIDconfigured()))
723		{
724			return false;
725		}
726
727		// check if token exists and is (still) valid
728		$tokenRepo = new OpenID\Repositories\AccessTokenRepository();
729		if (!($access_token = $tokenRepo->findToken($client, $account_id, 'PT1S', $token)))
730		{
731			return false;
732		}
733		$account_id = $access_token->getUserIdentifier();
734
735		// check if we need a second factor
736		if ($GLOBALS['egw_info']['server']['2fa_required'] !== 'disabled' &&
737			(($creds = Credentials::read(0, Credentials::TWOFA, $account_id)) ||
738				$GLOBALS['egw_info']['server']['2fa_required'] === 'strict'))
739		{
740			return false;
741		}
742
743		// access-token is sufficient
744		return true;
745	}
746
747	/**
748	 * Check multifcator authemtication
749	 *
750	 * @param string $code 2fa-code
751	 * @param string $token remember me token
752	 * @throws \Exception with error-message if NOT successful
753	 */
754	protected function checkMultifactorAuth($code, $token)
755	{
756		$errors = $factors = [];
757
758		if ($GLOBALS['egw_info']['server']['2fa_required'] === 'disabled')
759		{
760			return;	// nothing to check
761		}
762
763		// check if token exists and is (still) valid
764		if (!empty($token) && $GLOBALS['egw_info']['server']['remember_me_token'] !== 'disabled' &&
765			($client = $this->checkOpenIDconfigured()))
766		{
767			$tokenRepo = new OpenID\Repositories\AccessTokenRepository();
768			if ($tokenRepo->findToken($client, $this->account_id, 'PT1S', $token))
769			{
770				$factors['remember_me_token'] = true;
771			}
772			else
773			{
774				$errors['remember_me_token'] = lang("Invalid or expired 'remember me' token");
775			}
776		}
777
778		// if 2fa is configured by user, check it
779		if (($creds = Credentials::read(0, Credentials::TWOFA, $this->account_id)))
780		{
781			if (empty($code))
782			{
783				$errors['2fa_code'] = lang('2-Factor Authentication code required');
784			}
785			else
786			{
787				$google2fa = new Google2FA\Google2FA();
788				if (!empty($code) && $google2fa->verify($code, $creds['2fa_password']))
789				{
790					$factors['2fa_code'] = true;
791				}
792				else
793				{
794					$errors['2fa_code'] = lang('Invalid 2-Factor Authentication code');
795				}
796			}
797		}
798
799		// check for more factors and/or policies
800		// hook can add factors, errors or throw \Exception with error-message and -code
801		Hooks::process([
802			'location' => 'multifactor_policy',
803			'factors' => &$factors,
804			'errors' => &$errors,
805			'2fa_code' => $code,
806			'remember_me_token' => $token,
807		], [], true);
808
809		if (!count($factors) && (count($errors) ||
810			$GLOBALS['egw_info']['server']['2fa_required'] === 'strict'))
811		{
812			if (!empty($code) && isset($errors['2fa_code']))
813			{
814				// we log the missing factor, but externally only show "Bad Login or Password"
815				// to give no indication that the password was already correct
816				throw new \Exception(implode(', ', $errors), self::CD_BAD_LOGIN_OR_PASSWORD);
817			}
818			else
819			{
820				throw new \Exception(implode(', ', $errors), self::CD_SECOND_FACTOR_REQUIRED);
821			}
822		}
823	}
824
825	/**
826	 * Check if we need to set a remember me token/cookie
827	 *
828	 * @param string $remember_me =null "True" for checkbox checked, or periode for user-choice select-box eg. "P1W" or "" for NOT remember
829	 * @param string $token current remember me token
830	 * @param int& $expriation on return expiration time of new cookie
831	 * @return string new token to set as Cookieor null to not set a new one
832	 */
833	protected function checkSetRememberMeToken($remember_me, $token, &$expiration)
834	{
835		// do we need a new token
836		if (!empty($remember_me) && $GLOBALS['egw_info']['server']['remember_me_token'] !== 'disabled' &&
837			($client = $this->checkOpenIDconfigured()))
838		{
839			if (!empty($token))
840			{
841				// check if token exists and is (still) valid
842				$tokenRepo = new OpenID\Repositories\AccessTokenRepository();
843				if ($tokenRepo->findToken($client, $this->account_id, 'PT1S', $token))
844				{
845					return null;	// token still valid, no need to set it again
846				}
847			}
848			$lifetime = $this->rememberMeTokenLifetime(is_string($remember_me) ? $remember_me : null);
849			$expiration = $this->rememberMeTokenLifetime(is_string($remember_me) ? $remember_me : null, true);
850
851			$tokenFactory = new OpenID\Token();
852			if (($token = $tokenFactory->accessToken(self::OPENID_REMEMBER_ME_CLIENT_ID, [], $lifetime, false, $lifetime, false)))
853			{
854				return $token->getIdentifier();
855			}
856		}
857		return null;
858	}
859
860	/**
861	 * Check if 'remember me' token should be deleted on explict logout
862	 *
863	 * @return boolean false: if 2FA is enabeld for user, true: otherwise
864	 */
865	public function removeRememberMeTokenOnLogout()
866	{
867		return $GLOBALS['egw_info']['server']['2fa_required'] === 'disabled' ||
868			$GLOBALS['egw_info']['server']['2fa_required'] !== 'strict' &&
869			!($creds = Credentials::read(0, Credentials::TWOFA, $this->account_id));
870	}
871
872	/**
873	 * OpenID Client ID for remember me token
874	 */
875	const OPENID_REMEMBER_ME_CLIENT_ID = 'login-remember-me';
876
877	/**
878	 * Check and if not configure OpenID app to generate 'remember me' tokens
879	 *
880	 * @return OpenID\Entities\ClientEntity|null null if OpenID Server app is not installed
881	 */
882	protected function checkOpenIDconfigured()
883	{
884		// OpenID app not installed --> password authentication required
885		if (!isset($GLOBALS['egw_info']['apps']))
886		{
887			$GLOBALS['egw']->applications->read_installed_apps();
888		}
889		if (empty($GLOBALS['egw_info']['apps']['openid']))
890		{
891			return null;
892		}
893
894		$clients = new OpenID\Repositories\ClientRepository();
895		try {
896			$client = $clients->getClientEntity(self::OPENID_REMEMBER_ME_CLIENT_ID, null, null, false);	// false = do NOT check client-secret
897		}
898		catch (OAuthServerException $e)
899		{
900			unset($e);
901			$client = new OpenID\Entities\ClientEntity();
902			$client->setIdentifier(self::OPENID_REMEMBER_ME_CLIENT_ID);
903			$client->setSecret(Auth::randomstring(24));	// must not be unset
904			$client->setName(lang('Remember me token'));
905			$client->setAccessTokenTTL($this->rememberMeTokenLifetime());
906			$client->setRefreshTokenTTL('P0S');	// no refresh token
907			$client->setRedirectUri($GLOBALS['egw_info']['server']['webserver_url'].'/');
908			$clients->persistNewClient($client);
909		}
910		return $client;
911	}
912
913	/**
914	 * Return lifetime for remember me token
915	 *
916	 * @param string $user user choice, if allowed
917	 * @param boolean $ts =false false: return periode string, true: return integer timestamp
918	 * @return string periode spec eg. 'P1M'
919	 */
920	protected function rememberMeTokenLifetime($user=null, $ts=false)
921	{
922		switch ((string)$GLOBALS['egw_info']['server']['remember_me_lifetime'])
923		{
924			case 'user':
925				if (!empty($user))
926				{
927					$lifetime = $user;
928					break;
929				}
930				// fall-through for default lifetime
931			case '':	// default lifetime
932				$lifetime = 'P1M';
933				break;
934			default:
935				$lifetime = $GLOBALS['egw_info']['server']['remember_me_lifetime'];
936				break;
937		}
938		if ($ts)
939		{
940			$expiration = new DateTime('now', DateTime::$server_timezone);
941			$expiration->add(new \DateInterval($lifetime));
942			return $expiration->format('ts');
943		}
944		return $lifetime;
945	}
946
947	/**
948	 * Store eGW specific session-vars
949	 *
950	 * @param string $login
951	 * @param string $user_ip
952	 * @param int $now
953	 * @param string $session_flags
954	 */
955	private function register_session($login,$user_ip,$now,$session_flags)
956	{
957		// restore session vars set before session was started
958		if (is_array($this->required_files))
959		{
960			$_SESSION[self::EGW_REQUIRED_FILES] = !is_array($_SESSION[self::EGW_REQUIRED_FILES]) ? $this->required_files :
961				array_unique(array_merge($_SESSION[self::EGW_REQUIRED_FILES],$this->required_files));
962			unset($this->required_files);
963		}
964		$_SESSION[self::EGW_SESSION_VAR] = array(
965			'session_id'     => $this->sessionid,
966			'session_lid'    => $login,
967			'session_ip'     => $user_ip,
968			'session_logintime' => $now,
969			'session_dla'    => $now,
970			'session_action' => $_SERVER['PHP_SELF'],
971			'session_flags'  => $session_flags,
972			// we need the install-id to differ between serveral installs shareing one tmp-dir
973			'session_install_id' => $GLOBALS['egw_info']['server']['install_id']
974		);
975	}
976
977	/**
978	 * name of access-log table
979	 */
980	const ACCESS_LOG_TABLE = 'egw_access_log';
981
982	/**
983	 * Prefix used to log unsucessful login attempts in cache, if DB is unavailable
984	 */
985	const FALSE_IP_CACHE_PREFIX = 'false_ip-';
986	const FALSE_ID_CACHE_PREFIX = 'false_id-';
987
988	/**
989     * Write or update (for logout) the access_log
990	 *
991	 * We do NOT log anonymous sessions to not block website and also to cope with
992	 * high rate anon endpoints might be called creating a bottleneck in the egw_access_log table.
993	 *
994	 * @param string|int $sessionid nummeric or PHP session id or error-message for unsuccessful logins
995	 * @param string $login ='' account_lid (evtl. with domain) or '' for setting the logout-time
996	 * @param string $user_ip ='' ip to log
997	 * @param int $account_id =0 numerical account_id
998	 * @return int $sessionid primary key of egw_access_log for login, null otherwise
999	 */
1000	private function log_access($sessionid,$login='',$user_ip='',$account_id=0)
1001	{
1002		// do not log anything for anonymous sessions
1003		if ($this->session_flags === 'A')
1004		{
1005			return;
1006		}
1007		$now = time();
1008
1009		// if sessionid contains non-ascii chars (only happens for error-messages)
1010		// --> transliterate it to ascii, as session_php only allows ascii chars
1011		if (preg_match('/[^\x20-\x7f]/', $sessionid))
1012		{
1013			$sessionid = Translation::to_ascii($sessionid);
1014		}
1015
1016		if ($login)
1017		{
1018			$GLOBALS['egw']->db->insert(self::ACCESS_LOG_TABLE,array(
1019				'session_php' => $sessionid,
1020				'loginid'   => $login,
1021				'ip'        => $user_ip,
1022				'li'        => $now,
1023				'account_id'=> $account_id,
1024				'user_agent'=> $_SERVER['HTTP_USER_AGENT'],
1025				'session_dla'    => $now,
1026				'session_action' => $this->update_dla(false),	// dont update egw_access_log
1027			),false,__LINE__,__FILE__);
1028
1029			$_SESSION[self::EGW_SESSION_VAR]['session_logged_dla'] = $now;
1030
1031			$ret = $GLOBALS['egw']->db->get_last_insert_id(self::ACCESS_LOG_TABLE,'sessionid');
1032
1033			// if we can not store failed login attempts in database, store it in cache
1034			if (!$ret && !$account_id)
1035			{
1036				Cache::setInstance(__CLASS__, self::FALSE_IP_CACHE_PREFIX.$user_ip,
1037					1+Cache::getInstance(__CLASS__, self::FALSE_IP_CACHE_PREFIX.$user_ip),
1038					$GLOBALS['egw_info']['server']['block_time'] * 60);
1039
1040				Cache::setInstance(__CLASS__, self::FALSE_ID_CACHE_PREFIX.$login,
1041					1+Cache::getInstance(__CLASS__, self::FALSE_ID_CACHE_PREFIX.$login),
1042					$GLOBALS['egw_info']['server']['block_time'] * 60);
1043			}
1044		}
1045		else
1046		{
1047			if (!is_numeric($sessionid) && $sessionid == $this->sessionid && $this->sessionid_access_log)
1048			{
1049				$sessionid = $this->sessionid_access_log;
1050			}
1051			$GLOBALS['egw']->db->update(self::ACCESS_LOG_TABLE,array(
1052				'lo' => $now
1053			),is_numeric($sessionid) ? array(
1054				'sessionid' => $sessionid,
1055			) : array(
1056				'session_php' => $sessionid,
1057			),__LINE__,__FILE__);
1058
1059			// run maintenance only on logout, to not delay login
1060			if ($GLOBALS['egw_info']['server']['max_access_log_age'])
1061			{
1062				$max_age = $now - $GLOBALS['egw_info']['server']['max_access_log_age'] * 24 * 60 * 60;
1063
1064				$GLOBALS['egw']->db->delete(self::ACCESS_LOG_TABLE,"li < $max_age",__LINE__,__FILE__);
1065			}
1066		}
1067		//error_log(__METHOD__."('$sessionid', '$login', '$user_ip', $account_id) returning ".array2string($ret));
1068		return $ret;
1069	}
1070
1071	/**
1072	 * Protect against brute force attacks, block login if too many unsuccessful login attmepts
1073     *
1074	 * @param string $login account_lid (evtl. with domain)
1075	 * @param string $ip ip of the user
1076	 * @returns bool login blocked?
1077	 */
1078	private function login_blocked($login,$ip)
1079	{
1080		$block_time = time() - $GLOBALS['egw_info']['server']['block_time'] * 60;
1081
1082		$false_id = $false_ip = 0;
1083		foreach($GLOBALS['egw']->db->union(array(
1084			array(
1085				'table' => self::ACCESS_LOG_TABLE,
1086				'cols'  => "'false_ip' AS name,COUNT(*) AS num",
1087				'where' => array(
1088					'account_id' => 0,
1089					'ip' => $ip,
1090					"li > $block_time",
1091				),
1092			),
1093			array(
1094				'table' => self::ACCESS_LOG_TABLE,
1095				'cols'  => "'false_id' AS name,COUNT(*) AS num",
1096				'where' => array(
1097					'account_id' => 0,
1098					'loginid' => $login,
1099					"li > $block_time",
1100				),
1101			),
1102			array(
1103				'table' => self::ACCESS_LOG_TABLE,
1104				'cols'  => "'false_id' AS name,COUNT(*) AS num",
1105				'where' => array(
1106					'account_id' => 0,
1107					'loginid LIKE '.$GLOBALS['egw']->db->quote($login.'@%'),
1108					"li > $block_time",
1109				)
1110			),
1111		), __LINE__, __FILE__) as $row)
1112		{
1113			${$row['name']} += $row['num'];
1114		}
1115
1116		// check cache too, in case DB is readonly
1117		$false_ip += Cache::getInstance(__CLASS__, self::FALSE_IP_CACHE_PREFIX.$ip);
1118		$false_id += Cache::getInstance(__CLASS__, self::FALSE_ID_CACHE_PREFIX.$login);
1119
1120		// if IP matches one in the (comma-separated) whitelist
1121		// --> check with whitelists optional number (none means never block)
1122		$matches = null;
1123		if (!empty($GLOBALS['egw_info']['server']['unsuccessful_ip_whitelist']) &&
1124			preg_match_all('/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(:\d+)?/',
1125				$GLOBALS['egw_info']['server']['unsuccessful_ip_whitelist'], $matches) &&
1126			($key=array_search($ip, $matches[1])) !== false)
1127		{
1128			$blocked = !empty($matches[3][$key]) && $false_ip > $matches[3][$key];
1129		}
1130		else	// else check with general number
1131		{
1132			$blocked = $false_ip > $GLOBALS['egw_info']['server']['num_unsuccessful_ip'];
1133		}
1134		if (!$blocked)
1135		{
1136			$blocked = $false_id > $GLOBALS['egw_info']['server']['num_unsuccessful_id'];
1137		}
1138		//error_log(__METHOD__."('$login', '$ip') false_ip=$false_ip, false_id=$false_id --> blocked=".array2string($blocked));
1139
1140		if ($blocked && $GLOBALS['egw_info']['server']['admin_mails'] &&
1141			$GLOBALS['egw_info']['server']['login_blocked_mail_time'] < time()-5*60)	// max. one mail every 5mins
1142		{
1143			try {
1144				$mailer = new Mailer();
1145				// notify admin(s) via email
1146				$mailer->setFrom('eGroupWare@'.$GLOBALS['egw_info']['server']['mail_suffix']);
1147				$mailer->addHeader('Subject', lang("eGroupWare: login blocked for user '%1', IP %2",$login,$ip));
1148				$mailer->setBody(lang("Too many unsucessful attempts to login: %1 for the user '%2', %3 for the IP %4",$false_id,$login,$false_ip,$ip));
1149				foreach(preg_split('/,\s*/',$GLOBALS['egw_info']['server']['admin_mails']) as $mail)
1150				{
1151					$mailer->addAddress($mail);
1152				}
1153				$mailer->send();
1154			}
1155			catch(\Exception $e) {
1156				// ignore exception, but log it, to block the account and give a correct error-message to user
1157				error_log(__METHOD__."('$login', '$ip') ".$e->getMessage());
1158			}
1159			// save time of mail, to not send to many mails
1160			$config = new Config('phpgwapi');
1161			$config->read_repository();
1162			$config->value('login_blocked_mail_time',time());
1163			$config->save_repository();
1164		}
1165		return $blocked;
1166	}
1167
1168	/**
1169	 * Basename of scripts for which we create a pseudo session-id based on user-credentials
1170	 *
1171	 * @var array
1172	 */
1173	static $pseudo_session_scripts = array(
1174		'webdav.php', 'groupdav.php', 'remote.php'
1175	);
1176
1177	/**
1178	 * Get the sessionid from Cookie, Get-Parameter or basic auth
1179	 *
1180	 * @param boolean $only_basic_auth =false return only a basic auth pseudo sessionid, default no
1181	 * @return string|null (pseudo-)session-id use or NULL if no Cookie or Basic-Auth credentials
1182	 */
1183	static function get_sessionid($only_basic_auth=false)
1184	{
1185		// for WebDAV and GroupDAV we use a pseudo sessionid created from md5(user:passwd)
1186		// --> allows this stateless protocolls which use basic auth to use sessions!
1187		if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW']) &&
1188			(in_array(basename($_SERVER['SCRIPT_NAME']), self::$pseudo_session_scripts) ||
1189				$_SERVER['SCRIPT_NAME'] === '/Microsoft-Server-ActiveSync'))
1190		{
1191			// we generate a pseudo-sessionid from the basic auth credentials
1192			$sessionid = md5($_SERVER['PHP_AUTH_USER'].':'.$_SERVER['PHP_AUTH_PW'].':'.$_SERVER['HTTP_HOST'].':'.
1193				EGW_SERVER_ROOT.':'.self::getuser_ip().':'.filemtime(EGW_SERVER_ROOT.'/api/setup/setup.inc.php').
1194				// for ActiveSync we add the DeviceID
1195				(isset($_GET['DeviceId']) && $_SERVER['SCRIPT_NAME'] === '/Microsoft-Server-ActiveSync' ? ':'.$_GET['DeviceId'] : '').
1196				':'.$_SERVER['HTTP_USER_AGENT']);
1197			//error_log(__METHOD__."($only_basic_auth) HTTP_HOST=$_SERVER[HTTP_HOST], PHP_AUTH_USER=$_SERVER[PHP_AUTH_USER], DeviceId=$_GET[DeviceId]: sessionid=$sessionid");
1198		}
1199		// same for digest auth
1200		elseif (isset($_SERVER['PHP_AUTH_DIGEST']) &&
1201			in_array(basename($_SERVER['SCRIPT_NAME']), self::$pseudo_session_scripts))
1202		{
1203			// we generate a pseudo-sessionid from the digest username, realm and nounce
1204			// can't use full $_SERVER['PHP_AUTH_DIGEST'], as it changes (contains eg. the url)
1205			$data = Header\Authenticate::parse_digest($_SERVER['PHP_AUTH_DIGEST']);
1206			$sessionid = md5($data['username'].':'.$data['realm'].':'.$data['nonce'].':'.$_SERVER['HTTP_HOST'].
1207				EGW_SERVER_ROOT.':'.self::getuser_ip().':'.filemtime(EGW_SERVER_ROOT.'/api/setup/setup.inc.php').
1208				':'.$_SERVER['HTTP_USER_AGENT']);
1209		}
1210		elseif(!$only_basic_auth && isset($_REQUEST[self::EGW_SESSION_NAME]))
1211		{
1212			$sessionid = $_REQUEST[self::EGW_SESSION_NAME];
1213		}
1214		elseif(!$only_basic_auth && isset($_COOKIE[self::EGW_SESSION_NAME]))
1215		{
1216			$sessionid = $_COOKIE[self::EGW_SESSION_NAME];
1217		}
1218		else
1219		{
1220			$sessionid = null;
1221		}
1222		if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."() _SERVER[REQUEST_URI]='$_SERVER[REQUEST_URI]' returning ".print_r($sessionid,true));
1223		return $sessionid;
1224	}
1225
1226	/**
1227	 * Get request or cookie variable with higher precedence to $_REQUEST then $_COOKIE
1228	 *
1229	 * In php < 5.3 that's identical to $_REQUEST[$name], but php5.3+ does no longer register cookied in $_REQUEST by default
1230	 *
1231	 * As a workaround for a bug in Safari Version 3.2.1 (5525.27.1), where cookie first letter get's upcased, we check that too.
1232	 *
1233	 * @param string $name eg. 'kp3' or domain
1234	 * @return mixed null if it's neither set in $_REQUEST or $_COOKIE
1235	 */
1236	static function get_request($name)
1237	{
1238		return isset($_REQUEST[$name]) ? $_REQUEST[$name] :
1239			(isset($_COOKIE[$name]) ? $_COOKIE[$name] :
1240			(isset($_COOKIE[$name=ucfirst($name)]) ? $_COOKIE[$name] : null));
1241	}
1242
1243	/**
1244	 * Check to see if a session is still current and valid
1245	 *
1246	 * @param string $sessionid session id to be verfied
1247	 * @param string $kp3 ?? to be verified
1248	 * @return bool is the session valid?
1249	 */
1250	function verify($sessionid=null,$kp3=null)
1251	{
1252		if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."('$sessionid','$kp3') ".function_backtrace());
1253
1254		$fill_egw_info_and_repositories = !$GLOBALS['egw_info']['flags']['restored_from_session'];
1255
1256		if(!$sessionid)
1257		{
1258			$sessionid = self::get_sessionid();
1259			$kp3       = self::get_request('kp3');
1260		}
1261
1262		$this->sessionid = $sessionid;
1263		$this->kp3       = $kp3;
1264
1265
1266		if (!$this->sessionid)
1267		{
1268			if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."('$sessionid') get_sessionid()='".self::get_sessionid()."' No session ID");
1269			return false;
1270		}
1271
1272		switch (session_status())
1273		{
1274			case PHP_SESSION_DISABLED:
1275				throw new ErrorException('EGroupware requires the PHP session extension!');
1276			case PHP_SESSION_NONE:
1277				session_name(self::EGW_SESSION_NAME);
1278				session_id($this->sessionid);
1279				self::cache_control();
1280				session_start();
1281				break;
1282			case PHP_SESSION_ACTIVE:
1283				// session already started eg. by managementserver_client
1284		}
1285
1286		// check if we have a eGroupware session --> return false if not (but dont destroy it!)
1287		if (is_null($_SESSION) || !isset($_SESSION[self::EGW_SESSION_VAR]))
1288		{
1289			if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."('$sessionid') session does NOT exist!");
1290			return false;
1291		}
1292		$session =& $_SESSION[self::EGW_SESSION_VAR];
1293
1294		if ($session['session_dla'] <= time() - $GLOBALS['egw_info']['server']['sessions_timeout'])
1295		{
1296			if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."('$sessionid') session timed out!");
1297			$this->destroy($sessionid,$kp3);
1298			return false;
1299		}
1300
1301		$this->session_flags = $session['session_flags'];
1302
1303		$this->split_login_domain($session['session_lid'],$this->account_lid,$this->account_domain);
1304
1305		// This is to ensure that we authenticate to the correct domain (might not be default)
1306		if($GLOBALS['egw_info']['user']['domain'] && $this->account_domain != $GLOBALS['egw_info']['user']['domain'])
1307		{
1308			return false;	// session not verified, domain changed
1309		}
1310		$GLOBALS['egw_info']['user']['kp3'] = $this->kp3;
1311
1312		// allow xajax / notifications to not update the dla, so sessions can time out again
1313		if (!isset($GLOBALS['egw_info']['flags']['no_dla_update']) || !$GLOBALS['egw_info']['flags']['no_dla_update'])
1314		{
1315			$this->update_dla(true);
1316		}
1317		elseif ($GLOBALS['egw_info']['flags']['currentapp'] == 'notifications')
1318		{
1319			$this->update_notification_heartbeat();
1320		}
1321		$this->account_id = $GLOBALS['egw']->accounts->name2id($this->account_lid,'account_lid','u');
1322		if (!$this->account_id)
1323		{
1324			if (self::ERROR_LOG_DEBUG) error_log("*** Session::verify($sessionid) !accounts::name2id('$this->account_lid')");
1325			return false;
1326		}
1327
1328		$GLOBALS['egw_info']['user']['account_id'] = $this->account_id;
1329
1330		if ($fill_egw_info_and_repositories)
1331		{
1332			$GLOBALS['egw_info']['user'] = $this->read_repositories();
1333		}
1334		else
1335		{
1336			// restore apps to $GLOBALS['egw_info']['apps']
1337			$GLOBALS['egw']->applications->read_installed_apps();
1338
1339			// session only stores app-names, restore apps from egw_info[apps]
1340			if (isset($GLOBALS['egw_info']['user']['apps'][0]))
1341			{
1342				$GLOBALS['egw_info']['user']['apps'] = array_intersect_key($GLOBALS['egw_info']['apps'], array_flip($GLOBALS['egw_info']['user']['apps']));
1343			}
1344
1345			// set prefs, they are no longer stored in session
1346			$GLOBALS['egw_info']['user']['preferences'] = $GLOBALS['egw']->preferences->read_repository();
1347		}
1348
1349		if ($GLOBALS['egw']->accounts->is_expired($GLOBALS['egw_info']['user']))
1350		{
1351			if (self::ERROR_LOG_DEBUG) error_log("*** Session::verify($sessionid) accounts is expired");
1352			return false;
1353		}
1354		$this->passwd = base64_decode(Cache::getSession('phpgwapi', 'password'));
1355		if ($fill_egw_info_and_repositories)
1356		{
1357			$GLOBALS['egw_info']['user']['session_ip'] = $session['session_ip'];
1358			$GLOBALS['egw_info']['user']['passwd']     = $this->passwd;
1359		}
1360		if ($this->account_domain != $GLOBALS['egw_info']['user']['domain'])
1361		{
1362			if (self::ERROR_LOG_DEBUG) error_log("*** Session::verify($sessionid) wrong domain");
1363			return false;
1364		}
1365
1366		if ($GLOBALS['egw_info']['server']['sessions_checkip'])
1367		{
1368			if (strtoupper(substr(PHP_OS,0,3)) != 'WIN' && (!$GLOBALS['egw_info']['user']['session_ip'] ||
1369				$GLOBALS['egw_info']['user']['session_ip'] != $this->getuser_ip()))
1370			{
1371				if (self::ERROR_LOG_DEBUG) error_log("*** Session::verify($sessionid) wrong IP");
1372				return false;
1373			}
1374		}
1375
1376		if ($fill_egw_info_and_repositories)
1377		{
1378			$GLOBALS['egw']->acl->__construct($this->account_id);
1379			$GLOBALS['egw']->preferences->__construct($this->account_id);
1380			$GLOBALS['egw']->applications->__construct($this->account_id);
1381		}
1382		if (!$this->account_lid)
1383		{
1384			if (self::ERROR_LOG_DEBUG) error_log("*** Session::verify($sessionid) !account_lid");
1385			return false;
1386		}
1387
1388		// query accesslog-id, if not set in session (session is made persistent after login!)
1389		if (!$this->sessionid_access_log && $this->session_flags != 'A')
1390		{
1391			$this->sessionid_access_log = $GLOBALS['egw']->db->select(self::ACCESS_LOG_TABLE,'sessionid',array(
1392				'session_php' => $this->sessionid,
1393			),__LINE__,__FILE__)->fetchColumn();
1394			//error_log(__METHOD__."() sessionid=$this->sessionid --> sessionid_access_log=$this->sessionid_access_log");
1395		}
1396
1397		// check if we use cookies for the session, but no cookie set
1398		// happens eg. in sitemgr (when redirecting to a different domain) or with new java notification app
1399		if ($GLOBALS['egw_info']['server']['usecookies'] && isset($_REQUEST[self::EGW_SESSION_NAME]) &&
1400			$_REQUEST[self::EGW_SESSION_NAME] === $this->sessionid &&
1401			(!isset($_COOKIE[self::EGW_SESSION_NAME]) || $_COOKIE[self::EGW_SESSION_NAME] !== $_REQUEST[self::EGW_SESSION_NAME]))
1402		{
1403			if (self::ERROR_LOG_DEBUG) error_log("--> Session::verify($sessionid) SUCCESS, but NO required cookies set --> setting them now");
1404			self::egw_setcookie(self::EGW_SESSION_NAME,$this->sessionid);
1405			self::egw_setcookie('kp3',$this->kp3);
1406			self::egw_setcookie('domain',$this->account_domain);
1407		}
1408
1409		if (self::ERROR_LOG_DEBUG) error_log("--> Session::verify($sessionid) SUCCESS");
1410
1411		return true;
1412	}
1413
1414	/**
1415	 * Terminate a session
1416	 *
1417	 * @param int|string $sessionid nummeric or php session id of session to be terminated
1418	 * @param string $kp3
1419	 * @return boolean true on success, false on error
1420	 */
1421	function destroy($sessionid, $kp3='')
1422	{
1423		if (!$sessionid && $kp3)
1424		{
1425			return false;
1426		}
1427		$this->log_access($sessionid);	// log logout-time
1428
1429		if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."($sessionid,$kp3)");
1430
1431		if (is_numeric($sessionid))	// do we have a access-log-id --> get PHP session id
1432		{
1433			$sessionid = $GLOBALS['egw']->db->select(self::ACCESS_LOG_TABLE,'session_php',array(
1434					'sessionid' => $sessionid,
1435				),__LINE__,__FILE__)->fetchColumn();
1436		}
1437
1438		Hooks::process(array(
1439			'location'  => 'session_destroyed',
1440			'sessionid' => $sessionid,
1441		),'',true);	// true = run hooks from all apps, not just the ones the current user has perms to run
1442
1443		// Only do the following, if where working with the current user
1444		if (!$GLOBALS['egw_info']['user']['sessionid'] || $sessionid == $GLOBALS['egw_info']['user']['sessionid'])
1445		{
1446			// eg. SAML logout will fail, if there is no more session --> remove everything else
1447			$auth = new Auth();
1448			if (($needed = $auth->needSession()) && array_intersect($needed, array_keys($_SESSION)))
1449			{
1450				$_SESSION = array_intersect_key($_SESSION, array_flip($needed));
1451				Auth::backend($auth->backendType());	// backend is stored in session
1452				return true;
1453			}
1454			if (self::ERROR_LOG_DEBUG) error_log(__METHOD__." ********* about to call session_destroy!");
1455			session_unset();
1456			@session_destroy();
1457			// we need to (re-)load the eGW session-handler, as session_destroy unloads custom session-handlers
1458			if (function_exists('init_session_handler'))
1459			{
1460				init_session_handler();
1461			}
1462
1463			if ($GLOBALS['egw_info']['server']['usecookies'])
1464			{
1465				self::egw_setcookie(session_name());
1466			}
1467		}
1468		else
1469		{
1470			$this->commit_session();	// close our own session
1471
1472			session_id($sessionid);
1473			if (session_start())
1474			{
1475				session_destroy();
1476			}
1477		}
1478		return true;
1479	}
1480
1481	/**
1482	 * Generate a url which supports url or cookies based sessions
1483	 *
1484	 * Please note, the values of the query get url encoded!
1485	 *
1486	 * @param string $url a url relative to the egroupware install root, it can contain a query too
1487	 * @param array|string $extravars query string arguements as string or array (prefered)
1488	 * 	if string is used ambersands in vars have to be already urlencoded as '%26', function ensures they get NOT double encoded
1489	 * @return string generated url
1490	 */
1491	public static function link($url, $extravars = '')
1492	{
1493		//error_log(_METHOD__."(url='$url',extravars='".array2string($extravars)."')");
1494
1495		if ($url[0] != '/')
1496		{
1497			$app = $GLOBALS['egw_info']['flags']['currentapp'];
1498			if ($app != 'login' && $app != 'logout')
1499			{
1500				$url = $app.'/'.$url;
1501			}
1502		}
1503
1504		// append the url to the webserver url, but avoid more then one slash between the parts of the url
1505		$webserver_url = $GLOBALS['egw_info']['server']['webserver_url'];
1506		// patch inspired by vladimir kolobkov -> we should not try to match the webserver url against the url without '/' as delimiter,
1507		// as $webserver_url may be part of $url (as /egw is part of phpgwapi/js/egw_instant_load.html)
1508		if (($url[0] != '/' || $webserver_url != '/') && (!$webserver_url || strpos($url, $webserver_url.'/') === false))
1509		{
1510			if($url[0] != '/' && substr($webserver_url,-1) != '/')
1511			{
1512				$url = $webserver_url .'/'. $url;
1513			}
1514			else
1515			{
1516				$url = $webserver_url . $url;
1517			}
1518		}
1519
1520		if(isset($GLOBALS['egw_info']['server']['enforce_ssl']) && $GLOBALS['egw_info']['server']['enforce_ssl'])
1521		{
1522			if(substr($url ,0,4) != 'http')
1523			{
1524				$url = 'https://'.$_SERVER['HTTP_HOST'].$url;
1525			}
1526			else
1527			{
1528				$url = str_replace ( 'http:', 'https:', $url);
1529			}
1530		}
1531		$vars = array();
1532		// add session params if not using cookies
1533		if (!$GLOBALS['egw_info']['server']['usecookies'])
1534		{
1535			$vars[self::EGW_SESSION_NAME] = $GLOBALS['egw']->session->sessionid;
1536			$vars['kp3'] = $GLOBALS['egw']->session->kp3;
1537			$vars['domain'] = $GLOBALS['egw']->session->account_domain;
1538		}
1539
1540		// check if the url already contains a query and ensure that vars is an array and all strings are in extravars
1541		list($ret_url,$othervars) = explode('?', $url, 2);
1542		if ($extravars && is_array($extravars))
1543		{
1544			$vars += $extravars;
1545			$extravars = $othervars;
1546		}
1547		else
1548		{
1549			if ($othervars) $extravars .= ($extravars?'&':'').$othervars;
1550		}
1551
1552		// parse extravars string into the vars array
1553		if ($extravars)
1554		{
1555			foreach(explode('&',$extravars) as $expr)
1556			{
1557				list($var,$val) = explode('=', $expr,2);
1558				if (strpos($val,'%26') != false) $val = str_replace('%26','&',$val);	// make sure to not double encode &
1559				if (substr($var,-2) == '[]')
1560				{
1561					$vars[substr($var,0,-2)][] = $val;
1562				}
1563				else
1564				{
1565					$vars[$var] = $val;
1566				}
1567			}
1568		}
1569
1570		// if there are vars, we add them urlencoded to the url
1571		if (count($vars))
1572		{
1573			$query = array();
1574			foreach($vars as $key => $value)
1575			{
1576				if (is_array($value))
1577				{
1578					foreach($value as $val)
1579					{
1580						$query[] = $key.'[]='.urlencode($val);
1581					}
1582				}
1583				else
1584				{
1585					$query[] = $key.'='.urlencode($value);
1586				}
1587			}
1588			$ret_url .= '?' . implode('&',$query);
1589		}
1590		return $ret_url;
1591	}
1592
1593	/**
1594	 * Regexp to validate IPv4 and IPv6
1595	 */
1596	const IP_REGEXP = '/^(?>(?>([a-f0-9]{1,4})(?>:(?1)){7}|(?!(?:.*[a-f0-9](?>:|$)){8,})((?1)(?>:(?1)){0,6})?::(?2)?)|(?>(?>(?1)(?>:(?1)){5}:|(?!(?:.*[a-f0-9]:){6,})(?3)?::(?>((?1)(?>:(?1)){0,4}):)?)?(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?>\.(?4)){3}))$/iD';
1597
1598	/**
1599	 * Get the ip address of current users
1600	 *
1601	 * We remove further private IPs (from proxys) as they invalidate user
1602	 * sessions, when they change because of multiple proxys.
1603	 *
1604	 * @return string ip address
1605	 */
1606	public static function getuser_ip()
1607	{
1608		if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))
1609		{
1610			$forwarded_for = preg_replace('/, *10\..*$/', '', $_SERVER['HTTP_X_FORWARDED_FOR']);
1611			if (preg_match(self::IP_REGEXP, $forwarded_for))
1612			{
1613				return $forwarded_for;
1614			}
1615		}
1616		return $_SERVER['REMOTE_ADDR'];
1617	}
1618
1619	/**
1620	 * domain for cookies
1621	 *
1622	 * @var string
1623	 */
1624	private static $cookie_domain = '';
1625
1626	/**
1627	 * path for cookies
1628	 *
1629	 * @var string
1630	 */
1631	private static $cookie_path = '/';
1632
1633	/**
1634	 * secure cookie / send via https only
1635	 *
1636	 * @var bool
1637	 */
1638	private static $cookie_secure = false;
1639
1640	/**
1641	 * iOS web-apps will loose cookie if set with a livetime of 0 / session-cookie
1642	 *
1643	 * Therefore we set a fixed lifetime of 24h from session-start instead.
1644	 * Server-side session will timeout earliert anyway, if there's no activity.
1645	 */
1646	const IOS_SESSION_COOKIE_LIFETIME = 86400;
1647
1648	/**
1649	 * Set a cookie with eGW's cookie-domain and -path settings
1650	 *
1651	 * @param string $cookiename name of cookie to be set
1652	 * @param string $cookievalue ='' value to be used, if unset cookie is cleared (optional)
1653	 * @param int $cookietime =0 when cookie should expire, 0 for session only (optional)
1654	 * @param string $cookiepath =null optional path (eg. '/') if the eGW install-dir should not be used
1655	 */
1656	public static function egw_setcookie($cookiename,$cookievalue='',$cookietime=0,$cookiepath=null)
1657	{
1658		if (empty(self::$cookie_domain) || empty(self::$cookie_path))
1659		{
1660			self::set_cookiedomain();
1661		}
1662		if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."($cookiename,$cookievalue,$cookietime,$cookiepath,".self::$cookie_domain.")");
1663
1664		// if we are installed in iOS as web-app, we must not set a cookietime==0 (session-cookie),
1665		// as every change between apps will cause the cookie to get lost
1666		static $is_iOS = null;
1667		if (!$cookietime && !isset($is_iOS)) $is_iOS = (bool)preg_match('/^(iPhone|iPad|iPod)/i', Header\UserAgent::mobile());
1668
1669		if(!headers_sent())	// gives only a warning, but can not send the cookie anyway
1670		{
1671			$options = [
1672				'expires' => !$cookietime && $is_iOS ? time()+self::IOS_SESSION_COOKIE_LIFETIME : $cookietime,
1673				'path'    => is_null($cookiepath) ? self::$cookie_path : $cookiepath,
1674				'domain'  => self::$cookie_domain,
1675				// if called via HTTPS, only send cookie for https
1676				'secure'  => empty($GLOBALS['egw_info']['server']['insecure_cookies']) && Header\Http::schema() === 'https',
1677				'httponly' => true, // only allow cookie access via HTTP, not client-side via JavaScript
1678			];
1679			// admin specified to send SameSite cookie attribute AND we use PHP 7.3+
1680			if (!empty($GLOBALS['egw_info']['server']['cookie_samesite_attribute']) &&
1681				in_array($GLOBALS['egw_info']['server']['cookie_samesite_attribute'], ['Lax', 'Strict', 'None']))
1682			{
1683				$options['samesite'] = $GLOBALS['egw_info']['server']['cookie_samesite_attribute'];
1684			}
1685			if ((float)PHP_VERSION >= 7.3)
1686			{
1687				setcookie($cookiename, $cookievalue, $options);
1688			}
1689			else
1690			{
1691				setcookie($cookiename, $cookievalue,
1692					$options['expires'], $options['path'], $options['domain'], $options['secure'], $options['httponly']);
1693			}
1694		}
1695	}
1696
1697	/**
1698	 * Get cookie-domain and other cookie parameters used by EGroupware
1699	 *
1700	 * @param string& $path =null on return cookie path, by default "/", but can be configured
1701	 * @param bool& $secure =null on return
1702	 * @return string domain-name used (either configured one or current one with a leading dot eg. ".example.org")
1703	 */
1704	public function getCookieDomain(&$path=null, &$secure=null)
1705	{
1706		if (empty(self::$cookie_domain) || empty(self::$cookie_path))
1707		{
1708			self::set_cookiedomain();
1709		}
1710		$path = self::$cookie_path;
1711		$secure = self::$cookie_secure;
1712
1713		return self::$cookie_domain;
1714	}
1715
1716	/**
1717	 * Set the domain and path used for cookies
1718	 */
1719	private static function set_cookiedomain()
1720	{
1721		if (PHP_SAPI === "cli") return;	// gives warnings and has no benefit
1722
1723		if ($GLOBALS['egw_info']['server']['cookiedomain'])
1724		{
1725			// Admin set domain, eg. .domain.com to allow egw.domain.com and www.domain.com
1726			self::$cookie_domain = $GLOBALS['egw_info']['server']['cookiedomain'];
1727		}
1728		else
1729		{
1730			// Use HTTP_X_FORWARDED_HOST if set, which is the case behind a none-transparent proxy
1731			self::$cookie_domain = Header\Http::host();
1732		}
1733		// remove port from HTTP_HOST
1734		$arr = null;
1735		if (preg_match("/^(.*):(.*)$/",self::$cookie_domain,$arr))
1736		{
1737			self::$cookie_domain = $arr[1];
1738		}
1739		if (count(explode('.',self::$cookie_domain)) <= 1)
1740		{
1741			// setcookie dont likes domains without dots, leaving it empty, gets setcookie to fill the domain in
1742			self::$cookie_domain = '';
1743		}
1744		if (!$GLOBALS['egw_info']['server']['cookiepath'] ||
1745			!(self::$cookie_path = parse_url($GLOBALS['egw_info']['server']['webserver_url'],PHP_URL_PATH)))
1746		{
1747			self::$cookie_path = '/';
1748		}
1749
1750		// if called via HTTPS, only send cookie for https and only allow cookie access via HTTP (true)
1751		self::$cookie_secure = empty($GLOBALS['egw_info']['server']['insecure_cookies']) && Header\Http::schema() === 'https';
1752
1753		session_set_cookie_params(0, self::$cookie_path, self::$cookie_domain, self::$cookie_secure, true);
1754	}
1755
1756	/**
1757	 * Search the instance matching the request
1758	 *
1759	 * @param string $login on login $_POST['login'], $_SERVER['PHP_AUTH_USER'] or $_SERVER['REMOTE_USER']
1760	 * @param string $domain_requested usually self::get_request('domain')
1761	 * @param string &$default_domain usually $default_domain get's set eg. by sitemgr
1762	 * @param string|array $server_names usually array($_SERVER['HTTP_HOST'], $_SERVER['SERVER_NAME'])
1763	 * @param array $domains =null defaults to $GLOBALS['egw_domain'] from the header
1764	 * @return string $GLOBALS['egw_info']['user']['domain'] set with the domain/instance to use
1765	 */
1766	public static function search_instance($login,$domain_requested,&$default_domain,$server_names,array $domains=null)
1767	{
1768		if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."('$login','$domain_requested',".array2string($default_domain).".".array2string($server_names).".".array2string($domains).")");
1769
1770		if (is_null($domains)) $domains = $GLOBALS['egw_domain'];
1771
1772		if (!isset($default_domain) || !isset($domains[$default_domain]))	// allow to overwrite the default domain
1773		{
1774			foreach((array)$server_names as $server_name)
1775			{
1776				list($server_name) = explode(':', $server_name);	// remove port from HTTP_HOST
1777				if(isset($domains[$server_name]))
1778				{
1779					$default_domain = $server_name;
1780					break;
1781				}
1782				else
1783				{
1784					$parts = explode('.', $server_name);
1785					array_shift($parts);
1786					$domain_part = implode('.', $parts);
1787					if(isset($domains[$domain_part]))
1788					{
1789						$default_domain = $domain_part;
1790						break;
1791					}
1792					else
1793					{
1794						reset($domains);
1795						$default_domain = key($domains);
1796					}
1797					unset($domain_part);
1798				}
1799			}
1800		}
1801		if (isset($login))	// on login
1802		{
1803			if (strpos($login,'@') === false || count($domains) == 1)
1804			{
1805				$login .= '@' . (isset($_POST['logindomain']) ? $_POST['logindomain'] : $default_domain);
1806			}
1807			$parts = explode('@',$login);
1808			$domain = array_pop($parts);
1809			$GLOBALS['login'] = $login;
1810		}
1811		else	// on "normal" pageview
1812		{
1813			$domain = $domain_requested;
1814		}
1815		if (!isset($domains[$domain]))
1816		{
1817			$domain = $default_domain;
1818		}
1819		if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."() default_domain=".array2string($default_domain).', login='.array2string($login)." returning ".array2string($domain));
1820
1821		return $domain;
1822	}
1823
1824	/**
1825	 * Set action logged in access-log
1826	 *
1827	 * Non-ascii chars in $action get transliterate to ascii, as our session_action column allows only ascii.
1828	 *
1829	 * @param string $action
1830	 */
1831	public function set_action($action)
1832	{
1833		if (preg_match('/[^\x20-\x7f]/', $action))
1834		{
1835			$action = Translation::to_ascii($action);
1836		}
1837		$this->action = $action;
1838	}
1839
1840	/**
1841	 * Ignore dla logging for a maximum of 900s = 15min
1842	 */
1843	const MAX_IGNORE_DLA_LOG = 900;
1844
1845	/**
1846	 * Update session_action and session_dla (session last used time)
1847	 *
1848	 * @param boolean $update_access_log =false false: dont update egw_access_log table, but set $this->action
1849	 * @return string action as written to egw_access_log.session_action
1850	 */
1851	private function update_dla($update_access_log=false)
1852	{
1853		// This way XML-RPC users aren't always listed as xmlrpc.php
1854		if (isset($_GET['menuaction']))
1855		{
1856			list(, $action) = explode('.ajax_exec.template.', $_GET['menuaction']);
1857
1858			if (empty($action)) $action = $_GET['menuaction'];
1859		}
1860		else
1861		{
1862			$action = $_SERVER['PHP_SELF'];
1863			// remove EGroupware path, if not installed in webroot
1864			$egw_path = $GLOBALS['egw_info']['server']['webserver_url'];
1865			if ($egw_path[0] != '/') $egw_path = parse_url($egw_path,PHP_URL_PATH);
1866			if ($action == '/Microsoft-Server-ActiveSync')
1867			{
1868				$action .= '?Cmd='.$_GET['Cmd'].'&DeviceId='.$_GET['DeviceId'];
1869			}
1870			elseif ($egw_path)
1871			{
1872				list(,$action) = explode($egw_path,$action,2);
1873			}
1874		}
1875		$this->set_action($action);
1876
1877		// update dla in access-log table, if we have an access-log row (non-anonymous session)
1878		if ($this->sessionid_access_log && $update_access_log &&
1879			// ignore updates (session creation is written) of *dav, avatar and thumbnail, due to possible high volume of updates
1880			(!preg_match('#^(/webdav|/groupdav|/api/avatar|/api/thumbnail)\.php#', $this->action) ||
1881				(time() - $_SESSION[self::EGW_SESSION_VAR]['session_logged_dla']) > self::MAX_IGNORE_DLA_LOG) &&
1882			is_object($GLOBALS['egw']->db))
1883		{
1884			$_SESSION[self::EGW_SESSION_VAR]['session_logged_dla'] = time();
1885
1886			$GLOBALS['egw']->db->update(self::ACCESS_LOG_TABLE,array(
1887				'session_dla' => time(),
1888				'session_action' => $this->action,
1889			) + ($this->action === '/logout.php' ? array() : array(
1890				'lo' => null,	// just in case it was (automatic) timed out before
1891			)),array(
1892				'sessionid' => $this->sessionid_access_log,
1893			),__LINE__,__FILE__);
1894		}
1895
1896		$_SESSION[self::EGW_SESSION_VAR]['session_dla'] = time();
1897		$_SESSION[self::EGW_SESSION_VAR]['session_action'] = $this->action;
1898		if (self::ERROR_LOG_DEBUG) error_log(__METHOD__.'() _SESSION['.self::EGW_SESSION_VAR.']='.array2string($_SESSION[self::EGW_SESSION_VAR]));
1899
1900		return $this->action;
1901	}
1902
1903	/**
1904	 * Update notification_heartbeat time of session
1905	 */
1906	private function update_notification_heartbeat()
1907	{
1908		// update dla in access-log table, if we have an access-log row (non-anonymous session)
1909		if ($this->sessionid_access_log)
1910		{
1911			$GLOBALS['egw']->db->update(self::ACCESS_LOG_TABLE,array(
1912				'notification_heartbeat' => time(),
1913			),array(
1914				'sessionid' => $this->sessionid_access_log,
1915				'lo IS NULL',
1916			),__LINE__,__FILE__);
1917		}
1918	}
1919
1920	/**
1921	 * Read the diverse repositories / init classes with data from the just loged in user
1922	 *
1923	 * @return array used to assign to $GLOBALS['egw_info']['user']
1924	 */
1925	public function read_repositories()
1926	{
1927		$GLOBALS['egw']->acl->__construct($this->account_id);
1928		$GLOBALS['egw']->preferences->__construct($this->account_id);
1929		$GLOBALS['egw']->applications->__construct($this->account_id);
1930
1931		$user = $GLOBALS['egw']->accounts->read($this->account_id);
1932		// set homedirectory from auth_ldap or auth_ads, to be able to use it in vfs
1933		if (!isset($user['homedirectory']))
1934		{
1935			// authentication happens in login.php, which does NOT yet create egw-object in session
1936			// --> need to store homedirectory in session
1937			if(isset($GLOBALS['auto_create_acct']['homedirectory']))
1938			{
1939				Cache::setSession(__CLASS__, 'homedirectory',
1940					$user['homedirectory'] = $GLOBALS['auto_create_acct']['homedirectory']);
1941			}
1942			else
1943			{
1944				$user['homedirectory'] = Cache::getSession(__CLASS__, 'homedirectory');
1945			}
1946		}
1947		$user['preferences'] = $GLOBALS['egw']->preferences->read_repository();
1948		if (is_object($GLOBALS['egw']->datetime))
1949		{
1950			$GLOBALS['egw']->datetime->__construct();		// to set tz_offset from the now read prefs
1951		}
1952		$user['apps']        = $GLOBALS['egw']->applications->read_repository();
1953		$user['domain']      = $this->account_domain;
1954		$user['sessionid']   = $this->sessionid;
1955		$user['kp3']         = $this->kp3;
1956		$user['session_ip']  = $this->getuser_ip();
1957		$user['session_lid'] = $this->account_lid.'@'.$this->account_domain;
1958		$user['account_id']  = $this->account_id;
1959		$user['account_lid'] = $this->account_lid;
1960		$user['userid']      = $this->account_lid;
1961		$user['passwd']      = $this->passwd;
1962
1963		return $user;
1964	}
1965
1966	/**
1967	 * Splits a login-name into account_lid and eGW-domain/-instance
1968	 *
1969	 * @param string $login login-name (ie. user@default)
1970	 * @param string &$account_lid returned account_lid (ie. user)
1971	 * @param string &$domain returned domain (ie. domain)
1972	 */
1973	private function split_login_domain($login,&$account_lid,&$domain)
1974	{
1975		$parts = explode('@',$login);
1976
1977		//conference - for strings like vinicius@thyamad.com@default ,
1978		//allows that user have a login that is his e-mail. (viniciuscb)
1979		if (count($parts) > 1)
1980		{
1981			$probable_domain = array_pop($parts);
1982			//Last part of login string, when separated by @, is a domain name
1983			if (in_array($probable_domain,$this->egw_domains))
1984			{
1985				$got_login = true;
1986				$domain = $probable_domain;
1987				$account_lid = implode('@',$parts);
1988			}
1989		}
1990
1991		if (!$got_login)
1992		{
1993			$domain = $GLOBALS['egw_info']['server']['default_domain'];
1994			$account_lid = $login;
1995		}
1996	}
1997
1998	/**
1999	 * Create a hash from user and pw
2000	 *
2001	 * Can be used to check setup config user/password inside egroupware:
2002	 *
2003	 * if (Api\Session::user_pw_hash($user,$pw) === $GLOBALS['egw_info']['server']['config_hash'])
2004	 *
2005	 * @param string $user username
2006	 * @param string $password password or md5 hash of password if $allow_password_md5
2007	 * @param boolean $allow_password_md5 =false can password alread be an md5 hash
2008	 * @return string
2009	 */
2010	static function user_pw_hash($user,$password,$allow_password_md5=false)
2011	{
2012		$password_md5 = $allow_password_md5 && preg_match('/^[a-f0-9]{32}$/',$password) ? $password : md5($password);
2013
2014		$hash = sha1(strtolower($user).$password_md5);
2015
2016		return $hash;
2017	}
2018
2019	/**
2020	 * Initialise the used session handler
2021	 *
2022	 * @return boolean true if we have a session, false otherwise
2023	 * @throws \ErrorException if there is no PHP session support
2024	 */
2025	public static function init_handler()
2026	{
2027		switch(session_status())
2028		{
2029			case PHP_SESSION_DISABLED:
2030				throw new \ErrorException('EGroupware requires PHP session extension!');
2031			case PHP_SESSION_NONE:
2032				if (headers_sent()) return false;	// only gives warnings
2033				ini_set('session.use_cookies',0);	// disable the automatic use of cookies, as it uses the path / by default
2034				session_name(self::EGW_SESSION_NAME);
2035				if (($sessionid = self::get_sessionid()))
2036				{
2037					session_id($sessionid);
2038					self::cache_control();
2039					$ok = session_start();
2040					self::decrypt();
2041					if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."() sessionid=$sessionid, _SESSION[".self::EGW_SESSION_VAR.']='.array2string($_SESSION[self::EGW_SESSION_VAR]));
2042					return $ok;
2043				}
2044				break;
2045			case PHP_SESSION_ACTIVE:
2046				return true;	// session created by MServer
2047		}
2048		if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."() no active session!");
2049
2050		return false;
2051	}
2052
2053	/**
2054	 * Controling caching and expires header
2055	 *
2056	 * Headers are send based on given parameters or $GLOBALS['egw_info']['flags']['nocachecontrol']:
2057	 * - not set of false --> no caching (default)
2058	 * - true --> private caching by browser (no expires header)
2059	 * - "public" or integer --> public caching with given cache_expire in minutes or php.ini default session_cache_expire
2060	 *
2061	 * @param int $expire =null expiration time in seconds, default $GLOBALS['egw_info']['flags']['nocachecontrol'] or php.ini session.cache_expire
2062	 * @param int $private =null allows to set private caching with given expiration time, by setting it to true
2063	 */
2064	public static function cache_control($expire=null, $private=null)
2065	{
2066		if (is_null($expire) && isset($GLOBALS['egw_info']['flags']['nocachecontrol']) && is_int($GLOBALS['egw_info']['flags']['nocachecontrol']))
2067		{
2068			$expire = $GLOBALS['egw_info']['flags']['nocachecontrol'];
2069		}
2070		// session not yet started: use PHP session_cache_limiter() and session_cache_expires() functions
2071		if (!isset($_SESSION))
2072		{
2073			// controling caching and expires header
2074			if(!isset($expire) && (!isset($GLOBALS['egw_info']['flags']['nocachecontrol']) ||
2075				!$GLOBALS['egw_info']['flags']['nocachecontrol']))
2076			{
2077				session_cache_limiter('nocache');
2078			}
2079			elseif (isset($expire) || $GLOBALS['egw_info']['flags']['nocachecontrol'] === 'public' || is_int($GLOBALS['egw_info']['flags']['nocachecontrol']))
2080			{
2081				// allow public caching: proxys, cdns, ...
2082				if (isset($expire))
2083				{
2084					session_cache_expire((int)ceil($expire/60));	// in minutes
2085				}
2086				session_cache_limiter($private ? 'private' : 'public');
2087			}
2088			else
2089			{
2090				// allow caching by browser
2091				session_cache_limiter('private_no_expire');
2092			}
2093		}
2094		// session already started
2095		if (isset($_SESSION))
2096		{
2097			if ($expire && (session_cache_limiter() !== ($expire===true?'private_no_expire':'public') ||
2098				is_int($expire) && $expire/60 !== session_cache_expire()))
2099			{
2100				$file = $line = null;
2101				if (headers_sent($file, $line))
2102				{
2103					error_log(__METHOD__."($expire) called, but header already sent in $file: $line");
2104					return;
2105				}
2106				if($expire === true)	// same behavior as session_cache_limiter('private_no_expire')
2107				{
2108					header('Cache-Control: private, max-age='.(60*session_cache_expire()));
2109					header_remove('Expires');
2110				}
2111				elseif ($private)
2112				{
2113					header('Cache-Control: private, max-age='.$expire);
2114					header('Expires: ' . gmdate('D, d M Y H:i:s', time()+$expire) . ' GMT');
2115				}
2116				else
2117				{
2118					header('Cache-Control: public, max-age='.$expire);
2119					header('Expires: ' . gmdate('D, d M Y H:i:s', time()+$expire) . ' GMT');
2120				}
2121				// remove Pragma header, might be set by old header
2122				if (function_exists('header_remove'))	// PHP 5.3+
2123				{
2124					header_remove('Pragma');
2125				}
2126				else
2127				{
2128					header('Pragma:');
2129				}
2130			}
2131		}
2132	}
2133
2134	/**
2135	 * Get a session list (of the current instance)
2136	 *
2137	 * @param int $start
2138	 * @param string $sort ='DESC' ASC or DESC
2139	 * @param string $order ='session_dla' session_lid, session_id, session_started, session_logintime, session_action, or (default) session_dla
2140	 * @param boolean $all_no_sort =False skip sorting and limiting to maxmatchs if set to true
2141	 * @param array $filter =array() extra filter for sessions
2142	 * @return array with sessions (values for keys as in $sort)
2143	 */
2144	public static function session_list($start,$sort='DESC',$order='session_dla',$all_no_sort=False,array $filter=array())
2145	{
2146		$sessions = array();
2147		if (!preg_match('/^[a-z0-9_ ,]+$/i',$order_by=$order.' '.$sort) || $order_by == ' ')
2148		{
2149			$order_by = 'session_dla DESC';
2150		}
2151		$filter['lo'] = null;
2152		$filter[] = 'account_id>0';
2153		$filter[] = 'session_dla > '.(int)(time() - $GLOBALS['egw_info']['server']['sessions_timeout']);
2154		$filter[] = '(notification_heartbeat IS NULL OR notification_heartbeat > '.self::heartbeat_limit().')';
2155		foreach($GLOBALS['egw']->db->select(self::ACCESS_LOG_TABLE, '*', $filter, __LINE__, __FILE__,
2156			$all_no_sort ? false : $start, 'ORDER BY '.$order_by) as $row)
2157		{
2158			$sessions[$row['sessionid']] = $row;
2159		}
2160		return $sessions;
2161	}
2162
2163	/**
2164	 * Query number of sessions (not more then once every N secs)
2165	 *
2166	 * @param array $filter =array() extra filter for sessions
2167	 * @return int number of active sessions
2168	 */
2169	public static function session_count(array $filter=array())
2170	{
2171		$filter['lo'] = null;
2172		$filter[] = 'account_id>0';
2173		$filter[] = 'session_dla > '.(int)(time() - $GLOBALS['egw_info']['server']['sessions_timeout']);
2174		$filter[] = '(notification_heartbeat IS NULL OR notification_heartbeat > '.self::heartbeat_limit().')';
2175		return $GLOBALS['egw']->db->select(self::ACCESS_LOG_TABLE, 'COUNT(*)', $filter, __LINE__, __FILE__)->fetchColumn();
2176	}
2177
2178	/**
2179	 * Get limit / latest time of heartbeat for session to be active
2180	 *
2181	 * @return int TS in server-time
2182	 */
2183	public static function heartbeat_limit()
2184	{
2185		static $limit=null;
2186
2187		if (is_null($limit))
2188		{
2189			$config = Config::read('notifications');
2190			if (!($popup_poll_interval  = $config['popup_poll_interval']))
2191			{
2192				$popup_poll_interval = 60;
2193			}
2194			$limit = (int)(time() - $popup_poll_interval-10);	// 10s grace periode
2195		}
2196		return $limit;
2197	}
2198
2199	/**
2200	 * Check if given user can be reached via notifications
2201	 *
2202	 * Checks if notifications callback checked in not more then heartbeat_limit() seconds ago
2203	 *
2204	 * @param int $account_id
2205	 * @param int number of active sessions of given user with notifications running
2206	 */
2207	public static function notifications_active($account_id)
2208	{
2209		return $GLOBALS['egw']->db->select(self::ACCESS_LOG_TABLE, 'COUNT(*)', array(
2210				'lo' => null,
2211				'session_dla > '.(int)(time() - $GLOBALS['egw_info']['server']['sessions_timeout']),
2212				'account_id' => $account_id,
2213				'notification_heartbeat > '.self::heartbeat_limit(),
2214		), __LINE__, __FILE__)->fetchColumn();
2215	}
2216}
2217