From 239a6f12bdfda7a8d99e37deb33aabe728ca8cbb Mon Sep 17 00:00:00 2001 From: louis Date: Wed, 18 Dec 2024 18:20:41 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5=20Remove=20UsersCheckPasswordComma?= =?UTF-8?q?nd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/services.yaml | 7 - docs/development/tests.md | 16 - docs/installation/checkpassword.md | 43 --- docs/mail_crypt/index.md | 3 +- mkdocs.yml | 1 - src/Command/UsersCheckPasswordCommand.php | 190 ---------- .../Command/UsersCheckPasswordCommandTest.php | 341 ------------------ 7 files changed, 1 insertion(+), 600 deletions(-) delete mode 100644 docs/installation/checkpassword.md delete mode 100644 src/Command/UsersCheckPasswordCommand.php delete mode 100644 tests/Command/UsersCheckPasswordCommandTest.php diff --git a/config/services.yaml b/config/services.yaml index 4f63973e..ef7b3ca1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -52,13 +52,6 @@ services: $appUrl: "%env(APP_URL)%" $projectName: "%env(PROJECT_NAME)%" - App\Command\UsersCheckPasswordCommand: - arguments: - $mailCrypt: "%env(MAIL_CRYPT)%" - $mailLocation: "%env(DOVECOT_MAIL_LOCATION)%" - $mailUid: "%env(DOVECOT_MAIL_UID)%" - $mailGid: "%env(DOVECOT_MAIL_GID)%" - App\Command\UsersMailCryptCommand: arguments: $mailCrypt: "%env(MAIL_CRYPT)%" diff --git a/docs/development/tests.md b/docs/development/tests.md index 2ba2275b..e5df63c5 100644 --- a/docs/development/tests.md +++ b/docs/development/tests.md @@ -7,19 +7,3 @@ vagrant up && vagrant ssh make test make integration ``` - -## Test checkpassword script - -```shell -# Start vagrant box and login -vagrant up && vagrant ssh -# Create DB schema and load fixtures -bin/console doctrine:schema:create -bin/console doctrine:fixture:load -# Run `app:users:checkpassword` locally. First should return `0`, second `1` -echo -en 'user@example.org\0password' | ./bin/console app:users:checkpassword /bin/true; echo $? -echo -en 'user@example.org\0wrong' | ./bin/console app:users:checkpassword /bin/true; echo $? -# Logout from vagrant and test via IMAP login -exit -./tests/test_checkpassword_login.sh -``` diff --git a/docs/installation/checkpassword.md b/docs/installation/checkpassword.md deleted file mode 100644 index 242ad989..00000000 --- a/docs/installation/checkpassword.md +++ /dev/null @@ -1,43 +0,0 @@ -# Checkpassword - -The PHP console command `bin/console app:users:checkpassword` provides a -checkpassword command to be used for authentication (userdb and passdb -lookup) by external services. So far, it's only tested with Dovecot. - - -In order to use the userli checkpassword command with Dovecot (< 2.3), the -`default_vsz_limit` (defaults to 256MB) needs to be raised in the Dovecot -configuration. Starting with Dovecot 2.3, the default is 1G. - -Example configuration for using checkpassword in Dovecot: - -`/etc/dovecot/conf.d/auth-checkpassword.conf.ext`: - -```text -passdb { - driver = checkpassword - args = /path/to/userli/bin/console app:users:checkpassword -} - -userdb { - driver = prefetch -} - -userdb { - driver = checkpassword - args = /path/to/userli/bin/console app:users:checkpassword -} -``` - - -## Required permissions and sudo - -In order for checkpassword to work as expected, your Dovecot system user needs -read access to the userli application. - -In order to grant the required permissions, add the Dovecot system user to the -userli system group: - -```shell -adduser dovecot userli -``` diff --git a/docs/mail_crypt/index.md b/docs/mail_crypt/index.md index efc90caa..854ae92b 100644 --- a/docs/mail_crypt/index.md +++ b/docs/mail_crypt/index.md @@ -3,8 +3,7 @@ The software has builtin support for [Dovecot's mailbox encryption](https://wiki.dovecot.org/Plugins/MailCrypt), using the [global keys mode](https://wiki.dovecot.org/Plugins/MailCrypt#Global_keys). -Keys are created and maintained by userli and handed over to Dovecot via -`checkpassword` script. +Keys are created and maintained by userli and handed over to Dovecot via an API. The MailCrypt feature is enabled per default and can optionally be switched off globally by setting `MAIL_CRYPT=0` in the dotenv (`.env`) file. diff --git a/mkdocs.yml b/mkdocs.yml index cb196d52..700db5c9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,7 +14,6 @@ nav: - "Getting started": getting-started/index.md - Installation: - Installation: installation/index.md - - Checkpassword: installation/checkpassword.md - Commands: installation/commands.md - Configuration: installation/configuration.md - Customize: installation/customize.md diff --git a/src/Command/UsersCheckPasswordCommand.php b/src/Command/UsersCheckPasswordCommand.php deleted file mode 100644 index 4a824c6f..00000000 --- a/src/Command/UsersCheckPasswordCommand.php +++ /dev/null @@ -1,190 +0,0 @@ -repository = $manager->getRepository(User::class); - parent::__construct(); - } - - /** - * {@inheritdoc} - */ - protected function configure(): void - { - $this - ->setDescription('Checkpassword script for UserDB and PassDB authentication') - ->addArgument( - 'checkpassword-reply', - InputArgument::IS_ARRAY, - 'Optional checkpassword-reply command. Executed if authentication is successful. - Set to "/bin/true" to change input file descriptor to STDIN for testing purposes.' - ); - } - - /** - * {@inheritdoc} - * - * @throws Exception - */ - protected function execute(InputInterface $input, OutputInterface $output): ?int - { - trigger_error("UsersCheckPasswordCommand is deprecated. Use dovecot API with Lua auth instead.", E_USER_DEPRECATED); - - $replyArgs = $input->getArgument('checkpassword-reply'); - - $replyCommand = null; - if (0 < count($replyArgs)) { - $replyCommand = $replyArgs[0]; - } - - // Allow easy commandline testing - if ('/bin/true' === $replyCommand) { - $inputStream = $this->reader->readStdin(); - } else { - $inputStream = $this->reader->readFd3(); - } - - // Validate checkpassword input from file descriptor - $inputArgs = explode("\x0", $inputStream, 4); - - $email = array_shift($inputArgs); - $password = array_shift($inputArgs); - // timestamp and extra data are unused nowadays and ignored - // $timestamp = array_shift($inputArgs); - // $extra = array_shift($inputArgs); - - // Verify if an email address has been passed - if (empty($email)) { - throw new InvalidArgumentException('Invalid input format: missing argument email. See https://cr.yp.to/checkpwd/interface.html for documentation of the checkpassword interface.'); - } - - // Detect if invoked as UserDB lookup by dovecot (with env var AUTHORIZED='1') - // See https://wiki2.dovecot.org/AuthDatabase/CheckPassword#Checkpassword_as_userdb - $userDbLookup = '1' === getenv('AUTHORIZED'); - - if (false === $userDbLookup && empty($password)) { - // Instead of throwing an exception, just return 1 (invalid credentials) - // throw new InvalidArgumentException('Invalid input format: missing argument password. See https://cr.yp.to/checkpwd/interface.html for documentation of the checkpassword interface.'); - return 1; - } - - // Check if user exists - $user = $this->repository->findByEmail($email); - if (null === $user || $user->isDeleted()) { - // Return '3' for non-existent user when doing UserDB lookup for dovecot - if (true === $userDbLookup) { - return 3; - } - - return 1; - } - - // block spammers from login but not lookup - if (false === $userDbLookup && $user->hasRole(Roles::SPAM)) { - return 1; - } - - // Check if authentication credentials are valid - if (false === $userDbLookup && null === $user = $this->handler->authenticate($user, $password)) { - // TODO: return 111 in case of temporary lookup failure - return 1; - } - - // get email parts - [$username, $domain] = explode('@', $email); - - // Set default environment variables for checkpassword-reply command - $envVars = [ - 'USER' => $email, - 'HOME' => $this->mailLocation.DIRECTORY_SEPARATOR.$domain.DIRECTORY_SEPARATOR.$username, - 'userdb_uid' => $this->mailUid, - 'userdb_gid' => $this->mailGid, - ]; - - // set EXTRA env var - $envVars['EXTRA'] = 'userdb_uid userdb_gid'; - - // Optionally set quota environment variable for checkpassword-reply command - if (null !== $user->getQuota()) { - $envVars['EXTRA'] = sprintf('%s userdb_quota_rule', $envVars['EXTRA']); - $envVars['userdb_quota_rule'] = sprintf('*:storage=%dM', $user->getQuota()); - } - - // Optionally create mail_crypt key pair (when MAIL_CRYPT >= 3 and not $userDbLookup) - if ($this->mailCrypt >= 3 && - false === $userDbLookup && - false === $user->hasMailCrypt() && - null === $user->getMailCryptPublicKey()) { - $this->mailCryptKeyHandler->create($user, $password, true); - } - - // Optionally set mail_crypt environment variables for checkpassword-reply command - if ($this->mailCrypt >= 1 && $user->hasMailCrypt()) { - $envVars['EXTRA'] = sprintf('%s userdb_mail_crypt_save_version userdb_mail_crypt_global_public_key', $envVars['EXTRA']); - $envVars['userdb_mail_crypt_save_version'] = '2'; - $envVars['userdb_mail_crypt_global_public_key'] = $user->getMailCryptPublicKey(); - if (false === $userDbLookup) { - $envVars['EXTRA'] = sprintf('%s userdb_mail_crypt_global_private_key', $envVars['EXTRA']); - $envVars['userdb_mail_crypt_global_private_key'] = $this->mailCryptKeyHandler->decrypt($user, $password); - } - } - - // Optionally set environment variable AUTHORIZED for dovecot UserDB lookup - // See https://wiki2.dovecot.org/AuthDatabase/CheckPassword#Checkpassword_as_userdb - if (true === $userDbLookup) { - $envVars['AUTHORIZED'] = '2'; - } - - if (null === $replyCommand) { - return 0; - } - - // Execute checkpassword-reply command - $replyProcess = new Process($replyArgs); - $replyProcess->setEnv(array_merge(getenv(), $envVars)); - try { - $replyProcess->run(); - } catch (ProcessFailedException) { - throw new Exception(sprintf('Error at executing checkpassword-reply command %s: %s', $replyCommand, $replyProcess->getErrorOutput())); - } - - return $replyProcess->getExitCode(); - } -} diff --git a/tests/Command/UsersCheckPasswordCommandTest.php b/tests/Command/UsersCheckPasswordCommandTest.php deleted file mode 100644 index 4a89f553..00000000 --- a/tests/Command/UsersCheckPasswordCommandTest.php +++ /dev/null @@ -1,341 +0,0 @@ -plainUser = new User(); - $this->plainUser->setPassword('passwordhash'); - $this->quotaUser = new User(); - $this->quotaUser->setPassword('passwordhash'); - $this->quotaUser->setQuota(1024); - $this->mailCryptUser = new User(); - $this->mailCryptUser->setPassword('passwordhash'); - $this->mailCryptUser->setMailCrypt(true); - $this->mailCryptUser->setMailCryptPublicKey('somePublicKey'); - $this->spamUser = new User(); - $this->spamUser->setPassword('passwordhash'); - $this->spamUser->setRoles([Roles::SPAM]); - $this->deletedUser = new User(); - $this->deletedUser->setPassword('passwordhash'); - $this->deletedUser->setDeleted(true); - } - - /** - * @dataProvider invalidContentProvider - */ - public function testExecuteInvalidArgumentException($inputStream, $exceptionMessage): void - { - $this->expectException(InvalidArgumentException::class); - $manager = $this->getManager(); - $reader = $this->getReaderFd3($inputStream); - $handler = $this->getHandler(); - $mailCryptKeyHandler = $this->getMailCryptKeyHandler(); - $mailCrypt = 0; - $mailUID = 5000; - $mailGID = 5000; - $mailLocation = 'var/vmail'; - - $command = new UsersCheckPasswordCommand($manager, - $reader, - $handler, - $mailCryptKeyHandler, - $mailCrypt, - $mailUID, - $mailGID, - $mailLocation); - $commandTester = new CommandTester($command); - - $this->expectExceptionMessage($exceptionMessage); - $commandTester->execute([]); - } - - /** - * @dataProvider validContentProvider - */ - public function testExecuteFd3($inputStream, $returnCode): void - { - $manager = $this->getManager(); - $reader = $this->getReaderFd3($inputStream); - $handler = $this->getHandler(); - $mailCryptKeyHandler = $this->getMailCryptKeyHandler(); - $mailCrypt = 2; - $mailUID = 5000; - $mailGID = 5000; - $mailLocation = 'var/vmail'; - - $command = new UsersCheckPasswordCommand($manager, - $reader, - $handler, - $mailCryptKeyHandler, - $mailCrypt, - $mailUID, - $mailGID, - $mailLocation); - $commandTester = new CommandTester($command); - - $commandTester->execute([]); - - $this->assertEquals($returnCode, $commandTester->getStatusCode()); - } - - public function testExecuteCallsLoginListener(): void - { - $inputStream = "user@example.org\x00password"; - $returnCode = 0; - - $manager = $this->getManager(); - $reader = $this->getReaderFd3($inputStream); - $handler = $this->getHandler(); - $mailCryptKeyHandler = $this->getMailCryptKeyHandler(); - $mailCrypt = 2; - $mailUID = 5000; - $mailGID = 5000; - $mailLocation = 'var/vmail'; - - $command = new UsersCheckPasswordCommand($manager, - $reader, - $handler, - $mailCryptKeyHandler, - $mailCrypt, - $mailUID, - $mailGID, - $mailLocation); - $commandTester = new CommandTester($command); - - $this->loginListener->expects(self::once())->method('onLogin'); - $commandTester->execute([]); - self::assertEquals($returnCode, $commandTester->getStatusCode()); - } - - /** - * @dataProvider validContentProvider - */ - public function testExecuteStdin($inputStream, $returnCode): void - { - $manager = $this->getManager(); - $reader = $this->getReaderStdin($inputStream); - $handler = $this->getHandler(); - $mailCryptKeyHandler = $this->getMailCryptKeyHandler(); - $mailCrypt = 0; - $mailUID = 5000; - $mailGID = 5000; - $mailLocation = 'var/vmail'; - - $command = new UsersCheckPasswordCommand($manager, - $reader, - $handler, - $mailCryptKeyHandler, - $mailCrypt, - $mailUID, - $mailGID, - $mailLocation); - $commandTester = new CommandTester($command); - - $commandTester->execute( - [ - 'checkpassword-reply' => ['/bin/true'], - ] - ); - - self::assertEquals($returnCode, $commandTester->getStatusCode()); - } - - /** - * @dataProvider userDbContentProvider - */ - public function testExecuteUserDbLookup($inputStream, $returnCode): void - { - $manager = $this->getManager(); - $reader = $this->getReaderFd3($inputStream); - $handler = $this->getHandler(); - $mailCryptKeyHandler = $this->getMailCryptKeyHandler(); - $mailCrypt = 2; - $mailUID = 5000; - $mailGID = 5000; - $mailLocation = 'var/vmail'; - - putenv('AUTHORIZED=1'); - $command = new UsersCheckPasswordCommand($manager, - $reader, - $handler, - $mailCryptKeyHandler, - $mailCrypt, - $mailUID, - $mailGID, - $mailLocation); - $commandTester = new CommandTester($command); - - $commandTester->execute([]); - - self::assertEquals($returnCode, $commandTester->getStatusCode()); - putenv('AUTHORIZED'); - } - - public function invalidContentProvider(): array - { - $msgMissingEmail = 'Invalid input format: missing argument email. See https://cr.yp.to/checkpwd/interface.html for documentation of the checkpassword interface.'; - - return [ - ['', $msgMissingEmail], - ["\x00password", $msgMissingEmail], - ["\x00password\x00", $msgMissingEmail], - ["\x00password\x00timestamp\x00", $msgMissingEmail], - ["\x00password\x00timestamp\x00extra", $msgMissingEmail], - ]; - } - - public function validContentProvider(): array - { - return [ - ["user@example.org\x00password", 0], - ["user@example.org\x00password\x00", 0], - ["user@example.org\x00password\x00\x00", 0], - ["user@example.org\x00password\x00timestamp\x00", 0], - ["user@example.org\x00password\x00timestamp\x00extra", 0], - ["user@example.org\x00password\x00timestamp\x00extra\x00", 0], - ["quota@example.org\x00password\x00\x00", 0], - ["mailcrypt@example.org\x00password\x00\x00", 0], - ['user@example.org', 1], - ["user@example.org\x00", 1], - ["user@example.org\x00\x00", 1], - ["user@example.org\x00\x00\x00", 1], - ["user@example.org\x00\x00timestamp\x00", 1], - ["user@example.org\x00\x00timestamp\x00extra", 1], - ["spam@example.org\x00password\x00\x00", 1], - ["user@example.org\x00wrongpassword", 1], - ["user@example.org\x00wrongpassword\x00", 1], - ["user@example.org\x00wrongpassword\x00\x00", 1], - ["unknown@example.org\x00password", 1], - ["unknown@example.org\x00password\x00", 1], - ["unknown@example.org\x00password\x00\x00", 1], - ["unknown@example.org\x00password\x00timestamp\x00extra with \x00\x00", 1], - ["deleted@example.org\x00password\x00\x00", 1] - ]; - } - - public function userDbContentProvider(): array - { - return [ - ['user@example.org', 0], - ["user@example.org\x00", 0], - ["user@example.org\x00\x00", 0], - ["user@example.org\x00\x00\x00", 0], - ["user@example.org\x00password\x00timestamp\x00extra", 0], - ['quota@example.org', 0], - ['mailcrypt@example.org', 0], - ["unknown@example.org\x00password", 3], - ['spam@example.org', 0], - ]; - } - - public function getManager(): EntityManagerInterface - { - $manager = $this->getMockBuilder(EntityManagerInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $repository = $this->getMockBuilder(UserRepository::class) - ->disableOriginalConstructor() - ->getMock(); - - $repository->method('findByEmail')->willReturnMap( - [ - ['new@example.org', null], - ['user@example.org', $this->plainUser], - ['quota@example.org', $this->quotaUser], - ['mailcrypt@example.org', $this->mailCryptUser], - ['spam@example.org', $this->spamUser], - ['deleted@example.org', $this->deletedUser], - ] - ); - - $manager->method('getRepository')->willReturn($repository); - - return $manager; - } - - public function getHandler(): UserAuthenticationHandler - { - $passwordHasher = $this->createMock(PasswordHasherInterface::class); - $passwordHasher->method('verify')->willReturnMap( - [ - ['passwordhash', 'password', true], - ['passwordhash', 'wrongpassword', false], - ['passwordhash', '', false], - ] - ); - $passwordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); - $passwordHasherFactory->method('getPasswordHasher')->willReturn($passwordHasher); - - $this->loginListener = $this->createMock(LoginListener::class); - $eventDispatcher = new EventDispatcher(); - $eventDispatcher->addListener(LoginEvent::NAME, [$this->loginListener, 'onLogin']); - return new UserAuthenticationHandler($passwordHasherFactory, $eventDispatcher); - } - - public function getMailCryptKeyHandler(): MailCryptKeyHandler - { - $mailCryptKeyHandler = $this->getMockBuilder(MailCryptKeyHandler::class) - ->disableOriginalConstructor() - ->getMock(); - $mailCryptKeyHandler->method('decrypt')->willReturnMap( - [ - [$this->plainUser, 'password', ''], - [$this->quotaUser, 'password', ''], - [$this->mailCryptUser, 'password', 'somePrivateKey'], - [$this->spamUser, 'password', ''], - ] - ); - - return $mailCryptKeyHandler; - } - - public function getReaderStdin(string $inputStream): FileDescriptorReader - { - $reader = $this->getMockBuilder(FileDescriptorReader::class) - ->disableOriginalConstructor() - ->getMock(); - $reader->method('readStdin')->willReturn($inputStream); - - return $reader; - } - - public function getReaderFd3(string $inputStream): FileDescriptorReader - { - $reader = $this->getMockBuilder(FileDescriptorReader::class) - ->disableOriginalConstructor() - ->getMock(); - $reader->method('readFd3')->willReturn($inputStream); - - return $reader; - } -}