155 changes: 155 additions & 0 deletions src/library/FOSSBilling/Session.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2023 FOSSBilling
* Copyright 2011-2021 BoxBilling, Inc.
* SPDX-License-Identifier: Apache-2.0
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/

namespace FOSSBilling;

class Session implements \FOSSBilling\InjectionAwareInterface
{
private ?\Pimple\Container $di;
private \PdoSessionHandler $handler;
private string $securityMode;
private int $cookieLifespan;
private bool $secure;

public function setDi(\Pimple\Container|null $di): void
{
$this->di = $di;
}

public function getDi(): ?\Pimple\Container
{
return $this->di;
}

public function __construct(\PdoSessionHandler $handler, string $securityMode = 'regular', int $cookieLifespan = 7200, bool $secure = true)
{
$this->handler = $handler;
$this->securityMode = $securityMode;
$this->cookieLifespan = $cookieLifespan;
$this->secure = $secure;
}

public function setupSession()
{
if (php_sapi_name() === 'cli') {
return;
}

$this->canUseSession();

if (!headers_sent()) {
session_set_save_handler(
[$this->handler, 'open'],
[$this->handler, 'close'],
[$this->handler, 'read'],
[$this->handler, 'write'],
[$this->handler, 'destroy'],
[$this->handler, 'gc']
);
}

$currentCookieParams = session_get_cookie_params();
$currentCookieParams["httponly"] = true;
$currentCookieParams["lifetime"] = $this->cookieLifespan;
$currentCookieParams["secure"] = $this->secure;

$cookieParams = [
'lifetime' => $currentCookieParams["lifetime"],
'path' => $currentCookieParams["path"],
'domain' => $currentCookieParams["domain"],
'secure' => $currentCookieParams["secure"],
'httponly' => $currentCookieParams["httponly"]
];

if ($this->securityMode == 'strict') {
$cookieParams['samesite'] = 'Strict';
}

session_set_cookie_params($cookieParams);
session_start();

$this->updateFingerprint();
}

public function getId(): string
{
return session_id();
}

public function delete(string $key): void
{
unset($_SESSION[$key]);
}

public function get(string $key): mixed
{
return $_SESSION[$key] ?? null;
}

public function set(string $key, mixed $value): void
{
$_SESSION[$key] = $value;
}

public function destroy(string $type = ''): bool
{
switch ($type) {
case 'admin':
$this->delete('admin');
break;
case 'client':
$this->delete('client');
$this->delete('client_id');
break;
}

return session_destroy();
}

/**
* Checks both the fingerprint and age of the current session to see if it can be used.
* If the session can't be used, it's destroyed from the database, forcing a new one to be created.
*/
private function canUseSession():void
{
if (empty($_COOKIE['PHPSESSID'])) {
return;
}

$sessionID = $_COOKIE['PHPSESSID'];
$maxAge = time() - $this->di['config']['security']['cookie_lifespan'];

$fingerprint = new \FOSSBilling\Fingerprint;
$session = $this->di['db']->findOne('session', 'id = :id', [':id' => $sessionID]);

if (empty($session->fingerprint)) {
return;
}

if (!$fingerprint->checkFingerprint(json_decode($session->fingerprint, true)) || $session->modified_at <= $maxAge) {
$this->di['db']->trash($session);
unset($_COOKIE['PHPSESSID']);
}
}

/**
* Depending on the specifics, this will either set or update the fingerprint associated with the current session.
*/
private function updateFingerprint():void
{
$sessionID = $_COOKIE['PHPSESSID'] ?? session_id();
$session = $this->di['db']->findOne('session', 'id = :id', [':id' => $sessionID]);

$fingerprint = new \FOSSBilling\Fingerprint;
$session->fingerprint = json_encode($fingerprint->fingerprint());
$this->di['db']->store($session);
}
}
7 changes: 6 additions & 1 deletion src/library/FOSSBilling/UpdatePatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,12 @@ private function getPatches($patchLevel = 0): array
__DIR__ . DIRECTORY_SEPARATOR . 'library' . DIRECTORY_SEPARATOR . 'FileCache.php' => 'unlink',
];
$this->executeFileActions($fileActions);
}
},
34 => function() {
// Adds the new "fingerprint" to the session table, to allow us to fingerprint devices and help prevent against attacks such as session hijacking.
$q = "ALTER TABLE session ADD fingerprint TEXT;";
$this->executeSql($q);
},
];
ksort($patches, SORT_NATURAL);

Expand Down
7 changes: 1 addition & 6 deletions src/modules/Client/Api/Guest.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,9 @@ public function login($data)
throw new \Box_Exception('Please check your login details.', [], 401);
}

if (isset($data['remember'])) {
$email = $data['email'];
$cookie_time = (3600 * 24 * 30); // 30 days
setcookie('BOXCLR', 'e=' . base64_encode($email) . '&p=' . base64_encode($client->pass), time() + $cookie_time, '/');
}

$this->di['events_manager']->fire(['event' => 'onAfterClientLogin', 'params' => ['id' => $client->id, 'ip' => $this->ip]]);

session_regenerate_id();
$result = $service->toSessionArray($client);
$this->di['session']->set('client_id', $client->id);

Expand Down
14 changes: 14 additions & 0 deletions src/modules/Cron/Service.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ public function runCrons($interval = null)
$ss = $this->di['mod_service']('system');
$ss->setParamValue('last_cron_exec', date('Y-m-d H:i:s'), $create);

$this->clearOldSessions();
$this->di['logger']->setChannel('cron')->info('Cleared outdated sessions from the database');

$this->di['events_manager']->fire(['event' => 'onAfterAdminCronRun']);

$this->di['logger']->setChannel('cron')->info('Finished executing cron jobs');
Expand Down Expand Up @@ -111,4 +114,15 @@ public function isLate()

return $t1 < $t2;
}

private function clearOldSessions()
{
$sessions = $this->di['db']->findAll('session');
foreach ($sessions as $session){
$maxAge = time() - $this->di['config']['security']['cookie_lifespan'];
if($session->modified_at <= $maxAge){
$this->di['db']->trash($session);
}
}
}
}
2 changes: 1 addition & 1 deletion src/modules/Profile/Api/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public function get()
public function logout()
{
unset($_COOKIE['BOXADMR']);
$this->di['session']->delete('admin');
$this->di['session']->destroy('admin');
$this->di['logger']->info('Admin logged out');

return true;
Expand Down
6 changes: 1 addition & 5 deletions src/modules/Profile/Service.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,7 @@ public function changeClientPassword(\Model_Client $client, $new_password)

public function logoutClient()
{
if ($_COOKIE) { // testing env fix
setcookie('BOXCLR', '', time() - 3600, '/');
}
$this->di['session']->delete('client');
$this->di['session']->delete('client_id');
$this->di['session']->destroy('client');
$this->di['logger']->info('Logged out');

return true;
Expand Down
29 changes: 1 addition & 28 deletions src/modules/Staff/Service.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public function login($email, $password, $ip)
'role' => $model->role,
];

session_regenerate_id();
$this->di['session']->set('admin', $result);

$this->di['logger']->info(sprintf('Staff member %s logged in', $model->id));
Expand Down Expand Up @@ -498,8 +499,6 @@ public function createAdmin(array $data)
$newId = $this->di['db']->store($admin);

$this->di['logger']->info('Main administrator %s account created', $admin->email);
$this->_sendMail($admin, $data['password']);

$data['remember'] = true;

return $newId;
Expand Down Expand Up @@ -649,32 +648,6 @@ public function deleteLoginHistory(\Model_ActivityAdminHistory $model)
return true;
}

protected function _sendMail($admin, $admin_pass)
{
$admin_name = $admin->name;
$admin_email = $admin->email;

$client_url = $this->di['url']->link('/');
$admin_url = $this->di['url']->adminLink('/');

$content = "Hello, $admin_name. " . PHP_EOL;
$content .= 'You have successfully installed FOSSBilling at ' . BB_URL . PHP_EOL;
$content .= 'Access the client area at: ' . $client_url . PHP_EOL;
$content .= 'Access the admin area at: ' . $admin_url . ' with login details:' . PHP_EOL;
$content .= 'Email: ' . $admin_email . PHP_EOL;
$content .= 'Password: ' . $admin_pass . PHP_EOL . PHP_EOL;

$content .= 'Read the FOSSBilling documentation to get started https://fossbilling.org/docs' . PHP_EOL;
$content .= 'Thank you for using FOSSBilling.' . PHP_EOL;

$subject = sprintf('FOSSBilling is ready at "%s"', BB_URL);

$systemService = $this->di['mod_service']('system');
$from = $systemService->getParamValue('company_email');
$emailService = $this->di['mod_service']('Email');
$emailService->sendMail($admin_email, $from, $subject, $content);
}

public function authorizeAdmin($email, $plainTextPassword)
{
$model = $this->di['db']->findOne('Admin', 'email = ? AND status = ?', [$email, \Model_Admin::STATUS_ACTIVE]);
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/bb-library/Box/DiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public function testInjector()
$this->assertInstanceOf('Box_Url', $di['url']);
$this->assertInstanceOf('Box_EventManager', $di['events_manager']);

$this->assertInstanceOf('\Box_Session', $di['session']);
$this->assertInstanceOf('\FOSSBilling\Session', $di['session']);
$this->assertInstanceOf('Box_Authorization', $di['auth']);
$this->assertInstanceOf('Twig\Environment', $di['twig']);
$this->assertInstanceOf('\FOSSBilling\Tools', $di['tools']);
Expand Down
35 changes: 0 additions & 35 deletions tests/library/Box/Box_SessionTest.php

This file was deleted.

4 changes: 2 additions & 2 deletions tests/modules/Cart/ServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function testGetSessionCartExists()
->method('findOne')
->will($this->returnValue($model));

$sessionMock = $this->getMockBuilder("\Box_Session")
$sessionMock = $this->getMockBuilder("\FOSSBilling\Session")
->disableOriginalConstructor()
->getMock();
$sessionMock->expects($this->atLeastOnce())
Expand Down Expand Up @@ -111,7 +111,7 @@ public function testGetSessionCartDoesNotExist($sessionGetWillReturn, $getCurren
->method('store')
->will($this->returnValue(rand(1, 100)));

$sessionMock = $this->getMockBuilder("\Box_Session")
$sessionMock = $this->getMockBuilder("\FOSSBilling\Session")
->disableOriginalConstructor()
->getMock();
$sessionMock->expects($this->atLeastOnce())
Expand Down
2 changes: 1 addition & 1 deletion tests/modules/Client/Api/AdminTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ public function testlogin()
$serviceMock->expects($this->atLeastOnce())->
method('toSessionArray')->will($this->returnValue($sessionArray));

$sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
$sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
$sessionMock->expects($this->atLeastOnce())->
method('set');

Expand Down
2 changes: 1 addition & 1 deletion tests/modules/Client/Api/GuestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ public function testlogin()
$eventMock->expects($this->atLeastOnce())->
method('fire');

$sessionMock = $this->getMockBuilder('\Box_Session')
$sessionMock = $this->getMockBuilder('\FOSSBilling\Session')
->disableOriginalConstructor()
->getMock();

Expand Down
6 changes: 6 additions & 0 deletions tests/modules/Cron/ServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,19 @@ public function testrunCrons()
$eventsMock = $this->getMockBuilder('\Box_EventManager')->getMock();
$eventsMock->expects($this->atLeastOnce())
->method('fire');

$dbMock = $this->getMockBuilder('Box_Database')->getMock();
$dbMock->expects($this->atLeastOnce())
->method('findAll')
->will($this->returnValue([]));

$di = new \Pimple\Container();
$di['logger'] = new \Box_Log();
$di['events_manager'] = $eventsMock;
$di['api_system'] = $apiSystem;
$di['mod_service'] = $di->protect(function() use($systemServiceMock) {return $systemServiceMock;});
$serviceMock->setDi($di);
$di['db'] = $dbMock;

$result = $serviceMock->runCrons();
$this->assertTrue($result);
Expand Down
2 changes: 1 addition & 1 deletion tests/modules/Profile/Api/AdminTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function testGet()

public function testLogout()
{
$sessionMock = $this->getMockBuilder('\Box_Session')
$sessionMock = $this->getMockBuilder('\FOSSBilling\Session')
->disableOriginalConstructor()
->getMock();

Expand Down
4 changes: 2 additions & 2 deletions tests/modules/Profile/ServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -378,12 +378,12 @@ public function testChangeClientPassword()

public function testLogoutClient()
{
$sessionMock = $this->getMockBuilder("\Box_Session")
$sessionMock = $this->getMockBuilder("\FOSSBilling\Session")
->disableOriginalConstructor()
->getMock();

$sessionMock->expects($this->atLeastOnce())
->method("delete");
->method("destroy");

$di = new \Pimple\Container();
$di['logger'] = new \Box_Log();
Expand Down
23 changes: 2 additions & 21 deletions tests/modules/Staff/ServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function testLogin()
->method('findOne')
->will($this->returnValue($admin));

$sessionMock = $this->getMockBuilder('\Box_Session')
$sessionMock = $this->getMockBuilder('\FOSSBilling\Session')
->disableOriginalConstructor()
->getMock();
$sessionMock->expects($this->atLeastOnce())
Expand Down Expand Up @@ -1174,21 +1174,6 @@ public function testcreateAdmin()
$logMock = $this->getMockBuilder('\Box_Log')->getMock();

$systemService = $this->getMockBuilder('\Box\Mod\System\Service')->getMock();
$systemService->expects($this->atLeastOnce())
->method('getParamValue');

$emailServiceMock = $this->getMockBuilder('\Box\Mod\Email\Service')->getMock();
$emailServiceMock->expects($this->atLeastOnce())
->method('sendMail');

$urlMock = $this->getMockBuilder('\Box_Url')->getMock();
$urlMock->expects($this->atLeastOnce())
->method('link')
->willReturn('');
$urlMock->expects($this->atLeastOnce())
->method('adminLink')
->willReturn('');


$passwordMock = $this->getMockBuilder('\Box_Password')->getMock();
$passwordMock->expects($this->atLeastOnce())
Expand All @@ -1198,15 +1183,11 @@ public function testcreateAdmin()
$di = new \Pimple\Container();
$di['logger'] = $logMock;
$di['db'] = $dbMock;
$di['mod_service'] = $di->protect(function ($serviceName) use ($systemService, $emailServiceMock) {
$di['mod_service'] = $di->protect(function ($serviceName) use ($systemService) {
if ('system' == $serviceName) {
return $systemService;
}
if ('Email' == $serviceName) {
return $emailServiceMock;
}
});
$di['url'] = $urlMock;
$di['password'] = $passwordMock;

$service = new \Box\Mod\Staff\Service();
Expand Down
8 changes: 4 additions & 4 deletions tests/modules/System/ServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ public function testgetPendingMessages()
{
$di = new \Pimple\Container();

$sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
$sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
$sessionMock->expects($this->atLeastOnce())
->method('get')
->with('pending_messages')
Expand All @@ -351,7 +351,7 @@ public function testgetPendingMessages_GetReturnsNotArray()
{
$di = new \Pimple\Container();

$sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
$sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
$sessionMock->expects($this->atLeastOnce())
->method('get')
->with('pending_messages')
Expand All @@ -375,7 +375,7 @@ public function testsetPendingMessage()

$di = new \Pimple\Container();

$sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
$sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
$sessionMock->expects($this->atLeastOnce())
->method('set')
->with('pending_messages');
Expand All @@ -393,7 +393,7 @@ public function testclearPendingMessages()
{
$di = new \Pimple\Container();

$sessionMock = $this->getMockBuilder('\Box_Session')->disableOriginalConstructor()->getMock();
$sessionMock = $this->getMockBuilder('\FOSSBilling\Session')->disableOriginalConstructor()->getMock();
$sessionMock->expects($this->atLeastOnce())
->method('delete')
->with('pending_messages');
Expand Down