Skip to content
Open
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
15 changes: 15 additions & 0 deletions changelog/unreleased/41806
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Security: Temporarily lock out local accounts after repeated failed logins

We've added throttling of password authentication for local accounts. Core did
not limit the number of failed password attempts, so an account with a weak
password could be brute-forced online without any additional app installed.
After five failed attempts within 15 minutes an account is now locked for ten
minutes, and the login form explains when it can be tried again. The lockout
always expires on its own - no administrative unlock is needed and no account is
ever disabled permanently. A successful login clears the counter immediately.
Accounts of an external backend such as LDAP/AD are not affected, they are
throttled by their identity provider. The thresholds can be adjusted with the
new `account_lockout.*` config parameters.

https://github.com/owncloud/core/pull/41806
https://doc.owncloud.com/server/next/admin_manual/configuration/server/config_sample_php_parameters.html
28 changes: 28 additions & 0 deletions config/config.sample.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,34 @@
*/
'token_auth_enforced' => false,

/**
* Temporarily lock an account after too many failed password attempts.
*
* Only accounts of the built-in user backend are locked. Accounts provided by
* LDAP/AD or an OpenID Connect provider are left to the policy of that identity
* provider.
*
* The lockout always expires on its own, no administrative action is needed, and
* a successful login resets the counter immediately.
*/
'account_lockout.enabled' => true,

/**
* Number of failed password attempts which triggers the lockout.
*/
'account_lockout.max_attempts' => 5,

/**
* How long an account stays locked, in seconds.
*/
'account_lockout.duration' => 600,

/**
* Seconds of inactivity after which the counter of failed attempts is forgotten,
* so that occasional typos spread over a long time never add up to a lockout.
*/
'account_lockout.attempt_window' => 900,

/**
* Enforce strict login check with user backend
* If enabled, strict login check for password in user backend will be enforced,
Expand Down
4 changes: 3 additions & 1 deletion core/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

use OC\AppFramework\Utility\SimpleContainer;
use OC\AppFramework\Utility\TimeFactory;
use OC\Authentication\AccountLockout\AccountLockout;
use OC\Core\Controller\AppRegistryController;
use OC\Core\Controller\AvatarController;
use OC\Core\Controller\CloudController;
Expand Down Expand Up @@ -144,7 +145,8 @@ public function __construct(array $urlParams= []) {
$c->query('UserManager'),
$c->query('ServerContainer')->query('OC\Authentication\Token\IProvider'),
$c->query('TwoFactorAuthManager'),
$c->query('SecureRandom')
$c->query('SecureRandom'),
$c->query('ServerContainer')->query(AccountLockout::class)
);
});
$container->registerService('CloudController', static function (SimpleContainer $c) {
Expand Down
29 changes: 19 additions & 10 deletions core/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

namespace OC\Core\Controller;

use OC\Authentication\AccountLockout\AccountLockedException;
use OC\Authentication\TwoFactorAuth\Manager;
use OC\User\Session;
use OC_App;
Expand Down Expand Up @@ -266,20 +267,28 @@ public function showLoginForm($user, $redirect_url, $remember_login) {
*/
public function tryLogin($user, $password, $redirect_url, $timezone = null, $remember_login = null) {
$originalUser = $user;
$lockoutMessage = null;
// TODO: Add all the insane error handling
$loginResult = $this->userSession->login($user, $password);
if ($loginResult !== true && $this->config->getSystemValue('strict_login_enforced', false) !== true) {
$users = $this->userManager->getByEmail($user);
// we only allow login by email if unique
if (\count($users) === 1) {
$user = $users[0]->getUID();
$loginResult = $this->userSession->login($user, $password);
try {
$loginResult = $this->userSession->login($user, $password);
if ($loginResult !== true && $this->config->getSystemValue('strict_login_enforced', false) !== true) {
$users = $this->userManager->getByEmail($user);
// we only allow login by email if unique
if (\count($users) === 1) {
$user = $users[0]->getUID();
$loginResult = $this->userSession->login($user, $password);
}
}
} catch (AccountLockedException $e) {
// the lockout has to be explained on the login form - letting the
// exception through would render an error page instead
$loginResult = false;
$lockoutMessage = $e->getMessage();
}
if ($loginResult !== true) {
$this->session->set('loginMessages', [
['invalidpassword'], []
]);
$this->session->set('loginMessages', $lockoutMessage === null
? [['invalidpassword'], []]
: [[], [$lockoutMessage]]);
$args = [];
// Read current user and append if possible - we need to return the unmodified user otherwise we will leak the login name
if ($user !== null) {
Expand Down
17 changes: 16 additions & 1 deletion core/Controller/OcsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

namespace OC\Core\Controller;

use OC\Authentication\AccountLockout\AccountLockout;
use OC\OCS\Result;
use OCP\IDBConnection;
use OCP\IRequest;
Expand All @@ -44,6 +45,9 @@ class OcsController extends \OCP\AppFramework\OCSController {
/** @var IUserManager */
private $userManager;

/** @var AccountLockout */
private $accountLockout;

/**
* OccController constructor.
*
Expand All @@ -56,12 +60,14 @@ public function __construct(
IRequest $request,
IDBConnection $dbConnection,
IUserSession $userSession,
IUserManager $userManager
IUserManager $userManager,
AccountLockout $accountLockout
) {
parent::__construct($appName, $request);
$this->dbConnection = $dbConnection;
$this->userSession = $userSession;
$this->userManager = $userManager;
$this->accountLockout = $accountLockout;
}

/**
Expand Down Expand Up @@ -92,12 +98,21 @@ public function getConfig() {
*/
public function checkPerson($login, $password) {
if ($login && $password) {
// this endpoint verifies a password, so it has to respect the lockout
// as well - otherwise it stays an unthrottled oracle for the same
// passwords. The response is the one of a wrong password on purpose,
// it must not tell an attacker anything about the account.
if ($this->accountLockout->getRemainingLockTime($login) > 0) {
return new Result(null, 102);
}
$user = $this->userManager->checkPassword($login, $password);
if ($user !== false) {
$this->accountLockout->clearFailures($login, $user->getUID());
$xml = [];
$xml['person']['personid'] = $user->getUID();
return new Result($xml);
} else {
$this->accountLockout->recordFailure($login);
return new Result(null, 102);
}
} else {
Expand Down
19 changes: 18 additions & 1 deletion core/Controller/TokenController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
namespace OC\Core\Controller;

use OC\AppFramework\Http;
use OC\Authentication\AccountLockout\AccountLockout;
use OC\Authentication\Token\DefaultTokenProvider;
use OC\Authentication\Token\IProvider;
use OC\Authentication\Token\IToken;
Expand All @@ -46,19 +47,24 @@ class TokenController extends Controller {
/** @var ISecureRandom */
private $secureRandom;

/** @var AccountLockout */
private $accountLockout;

/**
* @param string $appName
* @param IRequest $request
* @param Manager $userManager
* @param DefaultTokenProvider $tokenProvider
* @param ISecureRandom $secureRandom
* @param AccountLockout $accountLockout
*/
public function __construct($appName, IRequest $request, UserManager $userManager, IProvider $tokenProvider, TwoFactorAuthManager $twoFactorAuthManager, ISecureRandom $secureRandom) {
public function __construct($appName, IRequest $request, UserManager $userManager, IProvider $tokenProvider, TwoFactorAuthManager $twoFactorAuthManager, ISecureRandom $secureRandom, AccountLockout $accountLockout) {
parent::__construct($appName, $request);
$this->userManager = $userManager;
$this->tokenProvider = $tokenProvider;
$this->secureRandom = $secureRandom;
$this->twoFactorAuthManager = $twoFactorAuthManager;
$this->accountLockout = $accountLockout;
}

/**
Expand All @@ -79,12 +85,23 @@ public function generateToken($user, $password, $name = 'unknown client') {
return $response;
}
$loginName = $user;

// this endpoint verifies a password, so it has to respect the lockout as
// well - otherwise it stays an unthrottled oracle for the same passwords
if ($this->accountLockout->getRemainingLockTime($loginName) > 0) {
$response = new JSONResponse();
$response->setStatus(Http::STATUS_UNAUTHORIZED);
return $response;
}

$user = $this->userManager->checkPassword($loginName, $password);
if ($user === false) {
$this->accountLockout->recordFailure($loginName);
$response = new JSONResponse();
$response->setStatus(Http::STATUS_UNAUTHORIZED);
return $response;
}
$this->accountLockout->clearFailures($loginName, $user->getUID());

if ($this->twoFactorAuthManager->isTwoFactorAuthenticated($user)) {
$resp = new JSONResponse();
Expand Down
68 changes: 68 additions & 0 deletions core/Migrations/Version20260904090000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php
/**
* @author Thomas Müller <thomas.mueller@tmit.eu>
*
* @copyright Copyright (c) 2026, ownCloud GmbH
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OC\Migrations;

use Doctrine\DBAL\Schema\Schema;
use OCP\Migration\ISchemaMigration;

/**
* Table holding the failed login attempt counters which drive the temporary
* lockout of local accounts.
*/
class Version20260904090000 implements ISchemaMigration {
public function changeSchema(Schema $schema, array $options) {
$prefix = $options['tablePrefix'];
if (!$schema->hasTable("{$prefix}account_lockouts")) {
$table = $schema->createTable("{$prefix}account_lockouts");
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'length' => 20,
]);
// the lockout key: the resolved account uid, or - for a login name
// which does not resolve to an account - the normalised login name.
// Longer than users.uid on purpose, login names are attacker supplied.
$table->addColumn('uid', 'string', [
'notnull' => true,
'length' => 128,
]);
$table->addColumn('fail_count', 'integer', [
'notnull' => true,
'length' => 4,
'default' => 0,
]);
// unix timestamp; null or in the past means not locked
$table->addColumn('locked_until', 'bigint', [
'notnull' => false,
'length' => 20,
]);
// unix timestamp of the most recent failure, drives the counter decay
$table->addColumn('last_fail_at', 'bigint', [
'notnull' => true,
'length' => 20,
'default' => 0,
]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['uid'], 'acc_lockouts_uid_idx');
$table->addIndex(['last_fail_at'], 'acc_lockouts_fail_at_idx');
}
}
}
39 changes: 39 additions & 0 deletions core/Migrations/Version20260904090001.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php
/**
* @author Thomas Müller <thomas.mueller@tmit.eu>
*
* @copyright Copyright (c) 2026, ownCloud GmbH
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OC\Migrations;

use OCP\Migration\IOutput;
use OCP\Migration\ISimpleMigration;

/**
* Fresh installations get the job from Setup::installBackgroundJobs(), existing
* installations need it added here.
*/
class Version20260904090001 implements ISimpleMigration {
/**
* @param IOutput $out
*/
public function run(IOutput $out) {
// spelled exactly like in Setup::installBackgroundJobs(), the job list
// compares the class name verbatim when it deduplicates
\OC::$server->getJobList()->add('\OC\Authentication\AccountLockout\ExpireLockoutsJob');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php
/**
* @author Thomas Müller <thomas.mueller@tmit.eu>
*
* @copyright Copyright (c) 2026, ownCloud GmbH
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/

namespace OC\Authentication\AccountLockout;

use OC\User\LoginException;

/**
* Thrown when a login is refused because the account is temporarily locked
* after too many failed password attempts.
*
* It is a LoginException so that existing callers which already render a
* LoginException - most importantly the DAV backend, which turns it into a
* 401 - keep working unchanged.
*/
class AccountLockedException extends LoginException {
}
Loading