A new editor needs a username and a password of at least 12 characters. A persistent login needs an unexpired key and an existing account. Removing an account revokes its persistent logins, and its sessions end.
251 lines
12 KiB
PHP
251 lines
12 KiB
PHP
<?php
|
|
namespace Xxx\Tests\Pages;
|
|
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use Xxx\Tests\Support\PageTestCase;
|
|
|
|
// publish/login.php, publish/logout.php and persistent logins
|
|
class LoginTest extends PageTestCase {
|
|
protected function setUp(): void {
|
|
parent::setUp();
|
|
$this->setTables(array('editors' => array(
|
|
array('username' => 'ed', 'password' => password_hash('secret', PASSWORD_BCRYPT, array('cost' => 4)), 'email' => 'ed@example.org'),
|
|
)));
|
|
}
|
|
|
|
// Open the login form, then send it; returns the answer
|
|
private function login(string $password = 'secret', array $fields = array()): array {
|
|
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
|
return $this->post('/publish/login', $fields + array('login' => '1', 'username' => 'ed', 'password' => $password,
|
|
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
|
|
}
|
|
|
|
/*
|
|
* After a login, the site goes back to the last page that the session
|
|
* showed. With the login form that is the login page itself, which
|
|
* sends an editor on to the edit page.
|
|
*/
|
|
private function assertLoggedIn(array $answer): void {
|
|
$this->assertRedirect('/publish/login', $answer);
|
|
$this->assertRedirect('/publish/edit', $this->get('/publish/login'));
|
|
}
|
|
|
|
public function testPlainHttpIsSentToHttps(): void {
|
|
$this->assertRedirect('https://site.test/publish/login', $this->get('/publish/login', array('X-Test-Plain' => '1')));
|
|
}
|
|
|
|
public function testAForgedHostDoesNotMoveTheHttpsRedirect(): void {
|
|
$this->assertRedirect('https://site.test/publish/login',
|
|
$this->get('/publish/login', array('X-Test-Plain' => '1', 'Host' => 'evil.example')));
|
|
}
|
|
|
|
public function testAnArrayUsernameIsRefused(): void {
|
|
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
|
$answer = $this->post('/publish/login', array('login' => '1', 'username' => array('ed'), 'password' => 'secret',
|
|
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
|
|
$this->assertSame(200, $answer['status']);
|
|
$this->assertStringContainsString('Access denied.', $answer['body']);
|
|
}
|
|
|
|
public function testTheFormHasATokenAndTheCaptcha(): void {
|
|
$body = $this->get('/publish/login')['body'];
|
|
$this->assertSame($this->session()['csrf_token'], $this->csrfToken($body));
|
|
$this->assertStringContainsString('data-sitekey="test-site-key"', $body);
|
|
}
|
|
|
|
public function testTheRightPasswordLogsIn(): void {
|
|
$this->get('/publish/login');
|
|
$before = $this->cookies['PHPSESSID'];
|
|
$this->assertLoggedIn($this->login('secret', array('remember' => NULL)));
|
|
// A new session id, and a new CSRF token with it
|
|
$this->assertNotSame($before, $this->cookies['PHPSESSID']);
|
|
$session = $this->session();
|
|
$this->assertSame('ed', $session['editor']);
|
|
$this->assertArrayNotHasKey('csrf_token', $session);
|
|
$this->assertArrayNotHasKey('PersistentAuth', $this->cookies);
|
|
}
|
|
|
|
public function testTheLoginGoesBackToTheLastPage(): void {
|
|
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
|
$this->get('/article?id=1');
|
|
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
|
|
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
|
|
$this->assertRedirect('/article?id=1', $answer);
|
|
$this->assertArrayNotHasKey('referrer', $this->session());
|
|
}
|
|
|
|
public function testAForgedHostDoesNotMoveTheLoginRedirect(): void {
|
|
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
|
$this->get('/article?id=1');
|
|
$answer = $this->request('POST', '/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
|
|
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'),
|
|
array('Host' => 'evil.example'));
|
|
$this->assertRedirect('/article?id=1', $answer);
|
|
}
|
|
|
|
public static function otherSites(): array {
|
|
return array(array('@evil.example/'), array('//evil.example/'), array('/\\evil.example/'), array('https://evil.example/'));
|
|
}
|
|
|
|
#[DataProvider('otherSites')]
|
|
public function testALastPageOnAnotherSiteIsIgnored(string $referrer): void {
|
|
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
|
$this->startSession(array('csrf_token' => $token, 'referrer' => $referrer));
|
|
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
|
|
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
|
|
$this->assertRedirect('/publish/edit', $answer);
|
|
}
|
|
|
|
public function testWithoutALastPageTheLoginGoesToTheEditPage(): void {
|
|
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
|
$this->startSession(array('csrf_token' => $token));
|
|
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
|
|
'csrf_token' => $token, 'g-recaptcha-response' => 'pass'));
|
|
$this->assertRedirect('/publish/edit', $answer);
|
|
}
|
|
|
|
public function testTheWrongPasswordIsDenied(): void {
|
|
$answer = $this->login('wrong');
|
|
$this->assertSame(200, $answer['status']);
|
|
$this->assertStringContainsString('Access denied. Please try again.', $answer['body']);
|
|
$this->assertArrayNotHasKey('editor', $this->session());
|
|
}
|
|
|
|
public function testAnUnknownEditorIsDenied(): void {
|
|
$answer = $this->login('secret', array('username' => 'nobody'));
|
|
$this->assertStringContainsString('Access denied. Please try again.', $answer['body']);
|
|
}
|
|
|
|
public function testTheTokenIsChecked(): void {
|
|
$this->get('/publish/login');
|
|
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret',
|
|
'csrf_token' => 'wrong', 'g-recaptcha-response' => 'pass'));
|
|
$this->assertStringContainsString('Access denied. Please try again.', $answer['body']);
|
|
$this->assertSame(array(), $this->queries('/FROM editors/'));
|
|
}
|
|
|
|
public function testTheCaptchaIsChecked(): void {
|
|
$answer = $this->login('secret', array('g-recaptcha-response' => 'fail'));
|
|
$this->assertStringContainsString('Please complete the captcha.', $answer['body']);
|
|
$this->assertSame(array(), $this->queries('/FROM editors/'));
|
|
}
|
|
|
|
public function testFiveFailuresBlockTheLogin(): void {
|
|
for ($i = 0; $i < 5; $i++)
|
|
$this->login('wrong');
|
|
$answer = $this->login('secret');
|
|
$this->assertStringContainsString('Too many failed logins. Please try again in 15 minutes.', $answer['body']);
|
|
$this->assertArrayNotHasKey('editor', $this->session());
|
|
}
|
|
|
|
public function testTheBlockIsPerAddress(): void {
|
|
for ($i = 0; $i < 5; $i++)
|
|
$this->login('wrong');
|
|
$token = $this->csrfToken($this->get('/publish/login')['body']);
|
|
$answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret', 'csrf_token' => $token,
|
|
'g-recaptcha-response' => 'pass'), array('X-Test-Address' => '198.51.100.7'));
|
|
$this->assertLoggedIn($answer);
|
|
}
|
|
|
|
public function testAnOldHashIsReplacedAtLogin(): void {
|
|
$this->setTables(array('editors' => array(array('username' => 'ed', 'password' => crypt('secret', '$1$abcdefgh$'), 'email' => ''))));
|
|
$this->assertLoggedIn($this->login());
|
|
$hash = $this->tables()['editors'][0]['password'];
|
|
$this->assertFalse(password_needs_rehash($hash, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT));
|
|
$this->assertTrue(password_verify('secret', $hash));
|
|
}
|
|
|
|
public function testAnEditorWhoIsLoggedInGoesToTheEditPage(): void {
|
|
$this->loginAs();
|
|
$this->assertRedirect('/publish/edit', $this->get('/publish/login'));
|
|
}
|
|
|
|
public function testRememberMeKeepsTheEditorLoggedIn(): void {
|
|
$this->login('secret', array('remember' => 'on'));
|
|
$this->assertMatchesRegularExpression('/^ed\|[0-9a-f]{64}$/', $this->cookies['PersistentAuth']);
|
|
$key = explode('|', $this->cookies['PersistentAuth'])[1];
|
|
$this->assertSame(array(hash('sha256', $key)), array_column($this->tables()['persistent_sessions'], 'session'));
|
|
|
|
// A new browser session with only the cookie
|
|
unset($this->cookies['PHPSESSID']);
|
|
$body = $this->get('/')['body'];
|
|
$this->assertStringContainsString('/publish/logout?token=', $body);
|
|
$this->assertSame('ed', $this->session()['editor']);
|
|
}
|
|
|
|
public function testAWrongPersistentKeyDoesNotLogIn(): void {
|
|
$this->setTables(array('persistent_sessions' => array(
|
|
array('username' => 'ed', 'session' => hash('sha256', 'right'), 'created' => gmdate('Y-m-d H:i:s')))));
|
|
$this->cookies['PersistentAuth'] = 'ed|wrong';
|
|
$body = $this->get('/')['body'];
|
|
$this->assertStringNotContainsString('/publish/logout', $body);
|
|
}
|
|
|
|
public function testAnExpiredPersistentKeyDoesNotLogIn(): void {
|
|
$this->setTables(array('editors' => $this->tables()['editors'], 'persistent_sessions' => array(
|
|
array('username' => 'ed', 'session' => hash('sha256', 'key'), 'created' => gmdate('Y-m-d H:i:s', strtotime('-1 year -1 day'))))));
|
|
$this->cookies['PersistentAuth'] = 'ed|key';
|
|
$body = $this->get('/')['body'];
|
|
$this->assertStringNotContainsString('/publish/logout', $body);
|
|
$this->assertArrayNotHasKey('editor', $this->session());
|
|
// The expired key and its cookie are removed
|
|
$this->assertArrayNotHasKey('PersistentAuth', $this->cookies);
|
|
$this->assertSame(array(), $this->tables()['persistent_sessions']);
|
|
}
|
|
|
|
public function testAPersistentKeyOfARemovedAccountDoesNotLogIn(): void {
|
|
$this->setTables(array('persistent_sessions' => array(
|
|
array('username' => 'ed', 'session' => hash('sha256', 'key'), 'created' => gmdate('Y-m-d H:i:s')))));
|
|
$this->cookies['PersistentAuth'] = 'ed|key';
|
|
$this->assertStringNotContainsString('/publish/logout', $this->get('/')['body']);
|
|
$this->assertArrayNotHasKey('editor', $this->session());
|
|
$this->assertArrayNotHasKey('PersistentAuth', $this->cookies);
|
|
}
|
|
|
|
public function testARemovedAccountEndsItsSession(): void {
|
|
$this->loginAs();
|
|
$this->setTables(array());
|
|
$this->assertRedirect('/publish/login', $this->get('/publish/edit'));
|
|
$this->assertArrayNotHasKey('editor', $this->session());
|
|
}
|
|
|
|
public function testLogoutNeedsTheToken(): void {
|
|
$this->loginAs();
|
|
$this->assertRedirect('/', $this->get('/publish/logout?token=wrong'));
|
|
$this->assertSame('ed', $this->session()['editor']);
|
|
}
|
|
|
|
public function testLogoutEndsTheSessionAndThePersistentLogin(): void {
|
|
$this->setTables(array('persistent_sessions' => array(
|
|
array('username' => 'ed', 'session' => hash('sha256', 'key'), 'created' => gmdate('Y-m-d H:i:s')),
|
|
array('username' => 'ed', 'session' => hash('sha256', 'other device'), 'created' => gmdate('Y-m-d H:i:s')))));
|
|
$token = $this->loginAs('ed', array('referrer' => '/article?id=2'));
|
|
$this->cookies['PersistentAuth'] = 'ed|key';
|
|
$session = self::$state . '/sessions/sess_' . $this->cookies['PHPSESSID'];
|
|
|
|
$answer = $this->get('/publish/logout?token=' . $token);
|
|
$this->assertRedirect('/article?id=2', $answer);
|
|
$this->assertFileDoesNotExist($session);
|
|
$this->assertArrayNotHasKey('PHPSESSID', $this->cookies);
|
|
$this->assertArrayNotHasKey('PersistentAuth', $this->cookies);
|
|
$this->assertSame(array(hash('sha256', 'other device')), array_column($this->tables()['persistent_sessions'], 'session'));
|
|
}
|
|
|
|
public function testAForgedHostDoesNotMoveTheLogoutRedirect(): void {
|
|
$token = $this->loginAs('ed', array('referrer' => '/article?id=2'));
|
|
$this->assertRedirect('/article?id=2',
|
|
$this->get('/publish/logout?token=' . $token, array('Host' => 'evil.example')));
|
|
}
|
|
|
|
#[DataProvider('otherSites')]
|
|
public function testLogoutIgnoresALastPageOnAnotherSite(string $referrer): void {
|
|
$token = $this->loginAs('ed', array('referrer' => $referrer));
|
|
$this->assertRedirect('/', $this->get('/publish/logout?token=' . $token));
|
|
}
|
|
|
|
public function testLogoutDoesNotGoBackToTheLogoutPage(): void {
|
|
$token = $this->loginAs('ed', array('referrer' => '/publish/logout'));
|
|
$this->assertRedirect('/', $this->get('/publish/logout?token=' . $token));
|
|
}
|
|
}
|