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 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 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)); } }