Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion lib/GaletteOAuth2/Authorization/UserHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 10 additions & 4 deletions lib/GaletteOAuth2/Controllers/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
81 changes: 81 additions & 0 deletions tests/GaletteOAuth2/Controllers/tests/units/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
Loading