Files
xxx/tests/php/Unit/SessionTest.php
T

276 lines
14 KiB
PHP

<?php
namespace Xxx\Tests\Unit;
use Xxx\Tests\Support\FakeDb;
use Xxx\Tests\Support\PhpProcess;
use Xxx\Tests\Support\SiteData;
use Xxx\Tests\Support\TestCase;
// CSRF tokens, editor logins and persistent logins in session.lib.php
class SessionTest extends TestCase {
protected function setUp(): void {
parent::setUp();
ini_set('session.save_path', $this->tmp);
ini_set('session.use_cookies', '0');
ini_set('session.cache_limiter', '');
}
protected function tearDown(): void {
if (session_status() === PHP_SESSION_ACTIVE)
session_destroy();
parent::tearDown();
}
public function testCsrfTokenIsRandomAndKept(): void {
$token = csrf_token();
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token);
$this->assertSame($token, csrf_token());
$this->assertSame($token, $_SESSION['csrf_token']);
$_SESSION = array();
$this->assertNotSame($token, csrf_token());
}
public function testCsrfTokenReplacesABadSessionValue(): void {
$_SESSION['csrf_token'] = array('not a string');
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', csrf_token());
}
public function testCsrfField(): void {
$this->assertSame('<input type="hidden" name="csrf_token" value="' . csrf_token() . '" />', csrf_field());
}
public function testCsrfValidAcceptsOnlyTheSessionToken(): void {
$token = csrf_token();
$this->assertTrue(csrf_valid($token));
$this->assertFalse(csrf_valid(strtoupper($token)));
$this->assertFalse(csrf_valid(''));
$this->assertFalse(csrf_valid($token . 'x'));
$this->assertFalse(csrf_valid(array($token)));
}
public function testCsrfValidReadsThePostedToken(): void {
$token = csrf_token();
$this->assertFalse(csrf_valid());
$this->request(array(), array('csrf_token' => $token));
$this->assertTrue(csrf_valid());
$this->request(array('csrf_token' => $token));
$this->assertFalse(csrf_valid(), 'a token in the query string is not enough');
}
public function testCsrfValidFailsWithoutASessionToken(): void {
$this->assertFalse(csrf_valid(''));
$this->assertFalse(csrf_valid('anything'));
}
public function testLoginEditorGivesANewSessionId(): void {
session_start();
$before = session_id();
$_SESSION['csrf_token'] = 'old token';
$_SESSION['referrer'] = '/about';
login_editor('ed');
$this->assertNotSame($before, session_id());
$this->assertSame('ed', $_SESSION['editor']);
$this->assertArrayNotHasKey('csrf_token', $_SESSION);
$this->assertSame('/about', $_SESSION['referrer']);
$this->assertFileDoesNotExist($this->tmp . '/sess_' . $before);
}
public function testPersistentCookieParts(): void {
$this->assertSame(array('', ''), persistent_cookie());
$_COOKIE['PersistentAuth'] = 'ed|key|with|bars';
$this->assertSame(array('ed', 'key|with|bars'), persistent_cookie());
$_COOKIE['PersistentAuth'] = 'nobar';
$this->assertSame(array('nobar', ''), persistent_cookie());
$_COOKIE['PersistentAuth'] = array('ed', 'key');
$this->assertSame(array('', ''), persistent_cookie());
}
public function testPersistentKeyHash(): void {
$this->assertSame(hash('sha256', 'key'), persistent_key_hash('key'));
}
public function testCreatePersistentLoginStoresOnlyAHash(): void {
$this->assertTrue(create_persistent_login('ed'));
$rows = SiteData::$tables['persistent_sessions'];
$this->assertCount(1, $rows);
$this->assertSame('ed', $rows[0]['username']);
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $rows[0]['session']);
$this->assertNull($GLOBALS['db'], 'the connection is closed again');
}
public function testCheckPersistentLoginWithoutACookie(): void {
$this->assertFalse(check_persistent_login());
$this->assertSame(array(), FakeDb::$log);
}
public function testCheckPersistentLoginWithAnIncompleteCookie(): void {
foreach (array('', 'ed', 'ed|', '|key') as $cookie) {
$_COOKIE['PersistentAuth'] = $cookie;
$this->assertFalse(check_persistent_login(), $cookie);
}
$this->assertSame(array(), FakeDb::$log);
}
public function testCheckPersistentLoginWithAWrongKey(): void {
SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash('right'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|wrong';
$this->assertFalse(check_persistent_login());
$this->assertArrayNotHasKey('editor', $_SESSION);
$query = FakeDb::matching('/FROM persistent_sessions/')[0];
$this->assertSame(array('ed', persistent_key_hash('wrong')), $query['params']);
}
public function testCheckPersistentLoginWithAKeyOfAnotherEditor(): void {
SiteData::$tables['persistent_sessions'][] = array('username' => 'ann', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|key';
$this->assertFalse(check_persistent_login());
}
public function testCheckPersistentLoginLogsInAndSwapsTheKey(): void {
SiteData::$tables['editors'][] = array('username' => 'ed', 'password' => '', 'email' => '');
session_start();
$before = session_id();
SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|key';
$this->assertTrue(check_persistent_login());
$this->assertSame('ed', $_SESSION['editor']);
$this->assertNotSame($before, session_id());
$rows = SiteData::$tables['persistent_sessions'];
$this->assertCount(1, $rows);
$this->assertNotSame(persistent_key_hash('key'), $rows[0]['session'], 'the used key is gone');
}
public function testAUsedPersistentCookieCannotBeReplayedAndOtherDevicesStillWork(): void {
SiteData::$tables['editors'][] = array('username' => 'ed', 'password' => '', 'email' => '');
foreach (array('first-device', 'second-device') as $key) {
SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash($key),
'created' => gmdate('Y-m-d H:i:s'));
}
session_start();
$_COOKIE['PersistentAuth'] = 'ed|first-device';
$this->assertTrue(check_persistent_login());
$this->assertSame('ed', $_SESSION['editor']);
$rotated = SiteData::$tables['persistent_sessions'];
$this->assertCount(2, $rotated);
$this->assertNotContains(persistent_key_hash('first-device'), array_column($rotated, 'session'));
$this->assertContains(persistent_key_hash('second-device'), array_column($rotated, 'session'));
// Replay the old cookie without the authenticated PHP session.
session_destroy();
$_SESSION = array();
session_id(bin2hex(random_bytes(16)));
session_start();
$_COOKIE['PersistentAuth'] = 'ed|first-device';
$this->assertFalse(check_persistent_login());
$this->assertArrayNotHasKey('editor', $_SESSION);
$this->assertSame($rotated, SiteData::$tables['persistent_sessions']);
session_destroy();
$_SESSION = array();
session_id(bin2hex(random_bytes(16)));
session_start();
$_COOKIE['PersistentAuth'] = 'ed|second-device';
$this->assertTrue(check_persistent_login());
$this->assertSame('ed', $_SESSION['editor']);
$hashes = array_column(SiteData::$tables['persistent_sessions'], 'session');
$this->assertCount(2, $hashes);
$this->assertNotContains(persistent_key_hash('second-device'), $hashes);
foreach ($rotated as $row) {
if ($row['session'] !== persistent_key_hash('second-device'))
$this->assertContains($row['session'], $hashes, 'the first device keeps its rotated token');
}
}
public function testCheckPersistentLoginRefusesAnExpiredKey(): void {
SiteData::$tables['editors'][] = array('username' => 'ed', 'password' => '', 'email' => '');
SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash('key'),
'created' => gmdate('Y-m-d H:i:s', strtotime('-1 year -1 day')));
$_COOKIE['PersistentAuth'] = 'ed|key';
$this->assertFalse(check_persistent_login());
$this->assertArrayNotHasKey('editor', $_SESSION ?? array());
$this->assertSame(array(), SiteData::$tables['persistent_sessions']);
}
public function testCheckPersistentLoginRefusesAKeyWithoutAnAccount(): void {
SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'));
$_COOKIE['PersistentAuth'] = 'ed|key';
$this->assertFalse(check_persistent_login());
$this->assertSame(array(), SiteData::$tables['persistent_sessions']);
}
public function testEditorExists(): void {
SiteData::$tables['editors'][] = array('username' => 'ed', 'password' => '', 'email' => '');
$this->assertTrue(editor_exists('ed'));
$this->assertFalse(editor_exists('ann'));
$this->assertFalse(editor_exists(''));
$this->assertFalse(editor_exists(array('ed')));
$this->assertNull($GLOBALS['db'], 'the connection is closed again');
}
public function testRemoveEditorRevokesItsPersistentLogins(): void {
SiteData::$tables['editors'] = array(array('username' => 'ed', 'password' => '', 'email' => ''),
array('username' => 'ann', 'password' => '', 'email' => ''));
SiteData::$tables['persistent_sessions'] = array(
array('username' => 'ed', 'session' => persistent_key_hash('one'), 'created' => gmdate('Y-m-d H:i:s')),
array('username' => 'ed', 'session' => persistent_key_hash('two'), 'created' => gmdate('Y-m-d H:i:s')),
array('username' => 'ann', 'session' => persistent_key_hash('three'), 'created' => gmdate('Y-m-d H:i:s')));
remove_editor('ed');
$this->assertSame(array('ann'), array_column(SiteData::$tables['editors'], 'username'));
$this->assertSame(array('ann'), array_column(SiteData::$tables['persistent_sessions'], 'username'));
}
public function testARemovedEditorIsLoggedOutOverHttps(): void {
$code = 'Xxx\Tests\Support\SiteData::install(); $_SERVER["HTTPS"] = "on"; session_id("abc123"); session_start(); $_SESSION["editor"] = "ed"; session_write_close();'
. ' $_COOKIE[session_name()] = "abc123"; include XXX_ROOT . "/session.lib.php";'
. ' echo json_encode(array($is_admin, $_SESSION["editor"] ?? NULL));';
$result = PhpProcess::runBare($code, array(), array('session.save_path' => $this->tmp, 'session.use_cookies' => '0', 'session.cache_limiter' => ''));
$this->assertSame('[false,null]', $result['stdout'], $result['stderr']);
}
public function testDeletePersistentLoginRemovesThisKeyAndOldKeys(): void {
SiteData::$tables['persistent_sessions'] = array(
array('username' => 'ed', 'session' => persistent_key_hash('mine'), 'created' => gmdate('Y-m-d H:i:s')),
array('username' => 'ed', 'session' => persistent_key_hash('other device'), 'created' => gmdate('Y-m-d H:i:s')),
array('username' => 'ann', 'session' => persistent_key_hash('old'), 'created' => '2000-01-01 00:00:00'),
array('username' => 'bo', 'session' => 'an unhashed key from before', 'created' => gmdate('Y-m-d H:i:s')),
);
$_COOKIE['PersistentAuth'] = 'ed|mine';
$this->assertTrue(delete_persistent_login());
$this->assertSame(array(persistent_key_hash('other device')), array_column(SiteData::$tables['persistent_sessions'], 'session'));
$this->assertCount(1, FakeDb::matching('/INTERVAL 1 YEAR.*CHAR_LENGTH\(session\) <> 64/s'));
}
public function testDeletePersistentLoginWithoutACookieStillReaps(): void {
delete_persistent_login();
$this->assertCount(0, FakeDb::matching('/WHERE username = \?/'));
$this->assertCount(1, FakeDb::matching('/CHAR_LENGTH/'));
}
// The top of session.lib.php runs on every page; it needs HTTPS and a session
public function testSessionStartsOnlyOverHttps(): void {
$code = 'Xxx\Tests\Support\SiteData::install(array("editors" => array(array("username" => "ed")))); $_SERVER["HTTPS"] = %s; session_id("abc123"); session_start(); $_SESSION["editor"] = "ed"; session_write_close();'
. ' $_COOKIE[session_name()] = "abc123"; include XXX_ROOT . "/session.lib.php";'
. ' echo json_encode(array($is_admin, $username ?? NULL, session_status() === PHP_SESSION_ACTIVE));';
$ini = array('session.save_path' => $this->tmp, 'session.use_cookies' => '0', 'session.cache_limiter' => '');
$https = PhpProcess::runBare(sprintf($code, '"on"'), array(), $ini);
$this->assertSame('[true,"ed",true]', $https['stdout'], $https['stderr']);
}
public function testNoSessionOverPlainHttp(): void {
$result = PhpProcess::runBare('include XXX_ROOT . "/session.lib.php"; echo json_encode(array($is_admin, session_status() === PHP_SESSION_ACTIVE));',
array(), array('session.save_path' => $this->tmp));
$this->assertSame('[false,false]', $result['stdout'], $result['stderr']);
}
public function testPersistentCookieLogsInOverHttps(): void {
$store = $this->tmp . '/tables.json';
file_put_contents($store, json_encode(array('editors' => array(array('username' => 'ed')), 'persistent_sessions' => array(
array('username' => 'ed', 'session' => persistent_key_hash('key'), 'created' => gmdate('Y-m-d H:i:s'))))));
$code = 'Xxx\Tests\Support\SiteData::install(json_decode(file_get_contents(' . var_export($store, TRUE) . '), TRUE));'
. ' $_SERVER["HTTPS"] = "on"; $_COOKIE["PersistentAuth"] = "ed|key"; include XXX_ROOT . "/session.lib.php";'
. ' echo json_encode(array($is_admin, $username ?? NULL));';
$result = PhpProcess::runBare($code, array(), array('session.save_path' => $this->tmp, 'session.use_cookies' => '0', 'session.cache_limiter' => ''));
$this->assertSame('[true,"ed"]', $result['stdout'], $result['stderr']);
}
}