diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index 66314310d..d5ace0d5a 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -500,10 +500,10 @@ function apppass_login($user, $pass, $extra = null, &$denied = false){ $key = strtolower($extra['service']) . '_access'; $mailbox_attributes = json_decode($row['mailbox_attributes'], true); - // app passwords never grant a protocol the mailbox itself is denied + // the protocols of the mailbox only limit direct user access, app passwords have their own if ($row['active'] != '1' || $row['mailbox_active'] != '1' || $row['domain_active'] != '1' || ($mailbox_attributes['shared'] ?? '0') == '1' - || ($extra['service'] != 'NONE' && ($row[$key] != '1' || (isset($mailbox_attributes[$key]) && $mailbox_attributes[$key] != '1')))) { + || ($extra['service'] != 'NONE' && $row[$key] != '1')) { // right password, but the app password does not allow this login $denied = true; continue; diff --git a/tests/php/Auth/AppPasswordLoginTest.php b/tests/php/Auth/AppPasswordLoginTest.php new file mode 100644 index 000000000..3195ec241 --- /dev/null +++ b/tests/php/Auth/AppPasswordLoginTest.php @@ -0,0 +1,113 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->pdo->sqliteCreateFunction('regexp', fn ($pattern, $value) => preg_match('/' . $pattern . '/', (string)$value) === 1, 2); + $this->pdo->exec("CREATE TABLE `domain` (`domain` TEXT PRIMARY KEY, `active` INTEGER NOT NULL DEFAULT 1)"); + $this->pdo->exec("CREATE TABLE `mailbox` (`username` TEXT PRIMARY KEY, `domain` TEXT, `kind` TEXT NOT NULL DEFAULT '', + `active` INTEGER NOT NULL DEFAULT 1, `attributes` TEXT)"); + $this->pdo->exec("CREATE TABLE `app_passwd` (`id` INTEGER PRIMARY KEY, `name` TEXT, `mailbox` TEXT, `password` TEXT, + `active` INTEGER NOT NULL DEFAULT 1, " . implode(', ', array_map(fn ($p) => "`$p` INTEGER NOT NULL DEFAULT 0", self::PROTOCOLS)) . ")"); + $GLOBALS['pdo'] = $this->pdo; + $_SESSION = []; + } + + protected function tearDown(): void + { + unset($GLOBALS['pdo']); + $_SESSION = []; + } + + public static function logins(): array + { + $off = array_fill_keys(self::PROTOCOLS, '0'); + return [ + // [mailbox, domain active, app password, service, password, result, denied] + 'imap' => [[], 1, ['imap_access' => 1], 'imap', 'app-secret', 'user', false], + 'imap, mailbox imap off' => [['attributes' => $off], 1, ['imap_access' => 1, 'smtp_access' => 1], 'imap', 'app-secret', 'user', false], + 'smtp, mailbox smtp off' => [['attributes' => $off], 1, ['imap_access' => 1, 'smtp_access' => 1], 'smtp', 'app-secret', 'user', false], + 'eas, mailbox eas off' => [['attributes' => $off], 1, ['eas_access' => 1], 'EAS', 'app-secret', 'user', false], + 'dav, mailbox dav off' => [['attributes' => $off], 1, ['dav_access' => 1], 'DAV', 'app-secret', 'user', false], + // denied: right password, login not allowed + 'protocol not in app password' => [[], 1, ['imap_access' => 1, 'smtp_access' => 1], 'pop3', 'app-secret', false, true], + 'app password inactive' => [[], 1, ['imap_access' => 1, 'active' => 0], 'imap', 'app-secret', false, true], + 'mailbox inactive' => [['active' => 0], 1, ['imap_access' => 1], 'imap', 'app-secret', false, true], + 'mailbox login disabled' => [['active' => 2], 1, ['imap_access' => 1], 'imap', 'app-secret', false, true], + 'domain inactive' => [[], 0, ['imap_access' => 1], 'imap', 'app-secret', false, true], + 'shared mailbox' => [['attributes' => ['shared' => '1']], 1, ['imap_access' => 1], 'imap', 'app-secret', false, true], + // failed: counted by netfilter + 'wrong password' => [[], 1, ['imap_access' => 1], 'imap', 'wrong', false, false], + 'resource mailbox' => [['kind' => 'location'], 1, ['imap_access' => 1], 'imap', 'app-secret', false, false], + ]; + } + + #[DataProvider('logins')] + public function testLogin(array $mailbox, int $domainActive, array $appPasswd, string $service, string $password, string|false $result, bool $denied): void + { + $this->addMailbox($mailbox, $domainActive); + $id = $this->addAppPasswd($appPasswd); + + $isDenied = false; + $this->assertSame($result, apppass_login('user@example.org', $password, ['service' => $service, 'is_internal' => true], $isDenied)); + $this->assertSame($denied, $isDenied); + if ($result !== false) { + $this->assertEquals($id, $_SESSION['app_passwd_id']); + } + } + + public function testUiLoginWithoutServiceIsRefused(): void + { + $this->addMailbox([], 1); + $this->addAppPasswd(array_fill_keys(self::PROTOCOLS, 1)); + + $this->assertFalse(apppass_login('user@example.org', 'app-secret', ['is_internal' => false])); + } + + public function testOtherAppPasswordOfTheMailboxIsTried(): void + { + $this->addMailbox(['attributes' => array_fill_keys(self::PROTOCOLS, '0')], 1); + $this->addAppPasswd(['eas_access' => 1], 'other-secret'); + $id = $this->addAppPasswd(['imap_access' => 1]); + + $this->assertSame('user', apppass_login('user@example.org', 'app-secret', ['service' => 'imap', 'is_internal' => true])); + $this->assertEquals($id, $_SESSION['app_passwd_id']); + } + + private function addMailbox(array $mailbox, int $domainActive): void + { + $attributes = array_merge(array_fill_keys(self::PROTOCOLS, '1'), ['shared' => '0'], $mailbox['attributes'] ?? []); + $this->pdo->prepare("INSERT INTO `domain` (`domain`, `active`) VALUES ('example.org', ?)")->execute([$domainActive]); + $this->pdo->prepare("INSERT INTO `mailbox` (`username`, `domain`, `kind`, `active`, `attributes`) VALUES ('user@example.org', 'example.org', ?, ?, ?)") + ->execute([$mailbox['kind'] ?? '', $mailbox['active'] ?? 1, json_encode($attributes)]); + } + + private function addAppPasswd(array $appPasswd, string $password = 'app-secret'): int + { + $row = array_merge(array_fill_keys(self::PROTOCOLS, 0), ['active' => 1], $appPasswd); + $hash = '{BLF-CRYPT}' . password_hash($password, PASSWORD_BCRYPT, ['cost' => 4]); + $this->pdo->prepare("INSERT INTO `app_passwd` (`name`, `mailbox`, `password`, " . implode(', ', array_map(fn ($c) => "`$c`", array_keys($row))) . ") + VALUES ('test', 'user@example.org', ?, " . implode(', ', array_fill(0, count($row), '?')) . ")") + ->execute(array_merge([$hash], array_values($row))); + return (int)$this->pdo->lastInsertId(); + } +} diff --git a/tests/php/Support/legacy_auth_functions.php b/tests/php/Support/legacy_auth_functions.php new file mode 100644 index 000000000..e6cdcfe05 --- /dev/null +++ b/tests/php/Support/legacy_auth_functions.php @@ -0,0 +1,11 @@ +