diff --git a/lib/GaletteOAuth2/Authorization/UserHelper.php b/lib/GaletteOAuth2/Authorization/UserHelper.php index 89096bf..df5e1be 100644 --- a/lib/GaletteOAuth2/Authorization/UserHelper.php +++ b/lib/GaletteOAuth2/Authorization/UserHelper.php @@ -12,6 +12,7 @@ use Analog\Analog; use DI\Attribute\Inject; +use Galette\Core\AuthThrottle; use Galette\Core\Db; use Galette\Core\History; use Galette\Core\Login; @@ -49,14 +50,36 @@ public function __construct( /** * Log in a member * + * @param string $nick Login + * @param string $password Password + * @param AuthThrottle $throttle Authentication throttle + * * @return int|false Member ID, false on failure */ - public function login(string $nick, string $password): int|false + public function login(string $nick, string $password, AuthThrottle $throttle): int|false { if (trim($nick) === '' || trim($password) === '') { return false; } + //evaluated before any credential check, so that a locked out caller + //costs nothing but a lookup + $delay = $throttle->getRetryDelay($nick); + if ($delay > 0) { + //no countdown on the page: how long is left measures what has been + //tried, and it belongs in the log + $this->flash->addMessage( + 'error_detected', + _T("Too many failed attempts. Please try again later.") + ); + $this->history->add(_T("Authentication throttled"), $nick); + Analog::log( + 'Authentication throttled for `' . $nick . '`, ' . $delay . ' seconds left.', + Analog::INFO + ); + return false; + } + if ($nick === $this->preferences->pref_admin_login) { $pw_superadmin = password_verify( $password, @@ -85,11 +108,13 @@ public function login(string $nick, string $password): int|false } if ($this->login->isLogged()) { + $throttle->recordSuccess($nick); $this->session->login = $this->login; $this->history->add(_T('Login')); return $this->login->id; } + $throttle->recordFailure($nick); $this->history->add(_T('Authentication failed'), $nick); $this->flash->addMessage( diff --git a/lib/GaletteOAuth2/Controllers/LoginController.php b/lib/GaletteOAuth2/Controllers/LoginController.php index 85c158d..8c665dd 100755 --- a/lib/GaletteOAuth2/Controllers/LoginController.php +++ b/lib/GaletteOAuth2/Controllers/LoginController.php @@ -13,6 +13,7 @@ use Analog\Analog; use DI\Attribute\Inject; use Galette\Controllers\AbstractPluginController; +use Galette\Core\AuthThrottle; use GaletteOAuth2\Authorization\UserAuthorizationException; use GaletteOAuth2\Authorization\UserHelper; use GaletteOAuth2\Repositories\ClientRepository; @@ -96,10 +97,11 @@ public function login(Request $request, Response $response): Response /** * Do login * - * @param Request $request Received request - * @param Response $response Response instance + * @param Request $request Received request + * @param Response $response Response instance + * @param AuthThrottle $throttle Authentication throttle */ - public function doLogin(Request $request, Response $response): Response + public function doLogin(Request $request, Response $response, AuthThrottle $throttle): Response { /** @phpstan-ignore-next-line */ if (OAUTH2_DEBUGSESSION) { @@ -114,7 +116,11 @@ public function doLogin(Request $request, Response $response): Response $this->session->isLoggedIn = 'no'; unset($this->session->client_id); $nick = (string)($params['login'] ?? ''); - $this->session->user_id = $uid = $this->userHelper->login($nick, (string)($params['password'] ?? '')); + $this->session->user_id = $uid = $this->userHelper->login( + $nick, + (string)($params['password'] ?? ''), + $throttle + ); Debug::log("UserHelper::login({$nick}) return '{$uid}'"); if (false === $uid) { diff --git a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php index 8759b8a..37a69bc 100644 --- a/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php +++ b/tests/GaletteOAuth2/Controllers/tests/units/LoginController.php @@ -355,4 +355,85 @@ public function testDoLoginSuccess(): void $this->assertSame('galette_cli', $this->session->client_id); $this->assertNotSame($sid, session_id()); } + + /** + * Failed logins are throttled, even the right password is refused afterwards + * + * @return void + */ + public function testDoLoginIsThrottled(): void + { + $this->getAdminMember($this->getMemberOne()); + $data = $this->dataAdherentOne(); + $this->session->request_args = [ + 'response_type' => 'code', + 'client_id' => 'galette_cli', + 'redirect_uri' => 'http://localhost:8888', + ]; + $threshold = $this->preferences->pref_throttle_account_ip_attempts; + + for ($i = 0; $i < $threshold; $i++) { + $test_response = $this->app->handle($this->doLoginRequest($data['login_adh'], 'wrong')); + $this->assertSame( + ['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_login')]], + $test_response->getHeaders() + ); + $this->expectLogEntry(Analog::WARNING, 'Passwords mismatch for login'); + } + + //the right password no longer gets through + $test_response = $this->app->handle($this->doLoginRequest($data['login_adh'], $data['mdp_adh'])); + + $this->assertSame( + ['Location' => [$this->routeparser->urlFor(OAUTH2_PREFIX . '_login')]], + $test_response->getHeaders() + ); + $this->assertSame('no', $this->session->isLoggedIn); + $this->assertFalse($this->session->user_id); + $this->expectLogEntry(Analog::INFO, 'Authentication throttled for `' . $data['login_adh'] . '`'); + } + + /** + * A successful login clears the failures counted on the account + * + * @return void + */ + public function testDoLoginSuccessClearsThrottle(): void + { + $this->getAdminMember($this->getMemberOne()); + $data = $this->dataAdherentOne(); + $this->session->request_args = [ + 'response_type' => 'code', + 'client_id' => 'galette_cli', + 'redirect_uri' => 'http://localhost:8888', + ]; + + $this->app->handle($this->doLoginRequest($data['login_adh'], 'wrong')); + $this->expectLogEntry(Analog::WARNING, 'Passwords mismatch for login'); + + $select = $this->zdb->select(\Galette\Core\AuthThrottle::TABLE); + $select->where( + [ + 'scope' => \Galette\Core\AuthThrottle::SCOPE_ACCOUNT, + 'identifier' => $data['login_adh'] + ] + ); + $this->assertSame(1, $this->zdb->execute($select)->count()); + + $this->app->handle($this->doLoginRequest($data['login_adh'], $data['mdp_adh'])); + $this->assertSame('yes', $this->session->isLoggedIn); + $this->assertSame(0, $this->zdb->execute($select)->count()); + } + + /** + * Build a login request + * + * @param string $login Login + * @param string $password Password + */ + private function doLoginRequest(string $login, string $password): \Slim\Psr7\Request + { + return $this->createRequest(route_name: OAUTH2_PREFIX . '_doLogin', method: 'POST') + ->withParsedBody(['login' => $login, 'password' => $password]); + } }