From cf9b408e963dc75e1d3ad8b53cfc1e5e0df9bb55 Mon Sep 17 00:00:00 2001 From: yena Date: Fri, 18 Oct 2024 17:58:58 +0200 Subject: [PATCH] Add dovecot API and lua authentication script --- .env | 8 + .env.test | 3 + checkpasswd.lua | 171 +++++++++++++++++++++ config/packages/security.yaml | 17 ++ config/services.yaml | 11 ++ src/Controller/DovecotController.php | 126 +++++++++++++++ src/Dto/DovecotPassdbDto.php | 21 +++ src/Enum/MailCrypt.php | 27 ++++ src/Security/DovecotAccessTokenHandler.php | 21 +++ tests/Controller/DovecotControllerTest.php | 130 ++++++++++++++++ 10 files changed, 535 insertions(+) create mode 100644 checkpasswd.lua create mode 100644 src/Controller/DovecotController.php create mode 100644 src/Dto/DovecotPassdbDto.php create mode 100644 src/Enum/MailCrypt.php create mode 100644 src/Security/DovecotAccessTokenHandler.php create mode 100644 tests/Controller/DovecotControllerTest.php diff --git a/.env b/.env index 7d1ea95c..d8a2f683 100644 --- a/.env +++ b/.env @@ -63,6 +63,14 @@ KEYCLOAK_API_IP_ALLOWLIST="127.0.0.1, ::1" # Warning: set a secure access token KEYCLOAK_API_ACCESS_TOKEN="insecure" +### Enable DOVECOT API ### +# Set to `true` to enable DOVECOT API +DOVECOT_API_ENABLED=false +# Access is restricted to these IPs (supports subnets like `10.0.0.1/24`) +DOVECOT_API_IP_ALLOWLIST="127.0.0.1, ::1" +# Warning: set a secure access token +DOVECOT_API_ACCESS_TOKEN="insecure" + ### Enable roundcube API ### # Set to `true` to enable roundcube API ROUNDCUBE_API_ENABLED=false diff --git a/.env.test b/.env.test index 8366bcfb..303169e5 100644 --- a/.env.test +++ b/.env.test @@ -27,3 +27,6 @@ KEYCLOAK_API_IP_ALLOWLIST="127.0.0.1, ::1" KEYCLOAK_API_ACCESS_TOKEN="insecure" ROUNDCUBE_API_ENABLED=true ROUNDCUBE_API_IP_ALLOWLIST="127.0.0.1, ::1" +DOVECOT_API_ENABLED=true +DOVECOT_API_IP_ALLOWLIST="127.0.0.1, ::1" +DOVECOT_API_ACCESS_TOKEN="insecure" diff --git a/checkpasswd.lua b/checkpasswd.lua new file mode 100644 index 00000000..24200e40 --- /dev/null +++ b/checkpasswd.lua @@ -0,0 +1,171 @@ +local json = require("json") +local math = require("math") + +-- env vars +local env_userli_token = os.getenv("USERLI_API_ACCESS_TOKEN") +local env_userli_host = os.getenv("USERLI_HOST") +local env_dovecot_debug = os.getenv("DOVECOT_LUA_DEBUG") or "" +local env_dovecot_agent = os.getenv("DOVECOT_LUA_AGENT") or "Dovecot-Lua-Auth" +local env_dovecot_max_attempts = os.getenv("DOVECOT_LUA_MAX_ATTEMPTS") or "3" +local env_dovecot_timeout = os.getenv("DOVECOT_LUA_MAX_ATTEMPTS") or "10000" +local env_dovecot_insecure = os.getenv("DOVECOT_LUA_INSECURE") or "false" + +-- log messages +local log_msg = {} +log_msg["env_userli_host"] = "Environment variable USERLI_HOST must not be empty" +log_msg["env_userli_token"] = "Environment variable USERLI_API_ACCESS_TOKEN must not be empty" +log_msg["userli-error"] = "Could not connect to Userli API: HTTP-status: " +log_msg["http-ok"] = "Lookup successfull" +log_msg["http-ok-malformed"] = "Lookup failed: HTTP-status is 200, but HTTP-response is malformed." +log_msg["http-failed"] = "Lookup failed: HTTP-status " +log_msg["http-unexpected"] = "Lookup failed: Unexpected HTTP-status: " + + +local protocol = "https" +if string.lower(env_dovecot_insecure) == "true" then + protocol = "http" +end +local api_path = "/api/dovecot" +local api_url = protocol .. "://" .. env_userli_host .. api_path + +local http_client = dovecot.http.client { + timeout = math.tointeger(env_dovecot_timeout); + max_attempts = math.tointeger(env_dovecot_max_attempts); + debug = string.lower(env_dovecot_debug) == "true"; + user_agent = env_dovecot_agent +} + +function script_init() + if not env_userli_token then + dovecot.i_error(log_msg["env_userli_token"]) + return 1 + end + if not env_userli_host then + dovecot.i_error(log_msg["env_userli_host"]) + return 1 + end + + -- Only added in dovecot 2.4.0 + -- dns = dns_client:lookup(userli_host, auth) + -- if not dns then + -- dovecot.i_error("Cannot resolve userli hostname: " .. env_userli_host) + -- return 1 + -- end + + -- test if userli api is available + local http_request = http_client:request { + url = api_url .. "/" .. "status"; + method = "GET"; + } + http_request:add_header("Content-Type","application/json") + http_request:add_header("Authorization","Bearer " .. env_userli_token) + local http_response = http_request:submit() + if http_response:status() == 200 then + return 0 + else + dovecot.i_error(log_msg["userli-error"] .. http_response:status()) + return 1 + end +end + +function script_deinit() + return 0 +end + +function auth_userdb_lookup(request) + local http_request = http_client:request { + url = api_url .. "/" .. request.original_user; + method = "GET"; + } + http_request:add_header("Content-Type","application/json") + http_request:add_header("Authorization","Bearer " .. env_userli_token) + local http_response = http_request:submit() + + if http_response:status() == 200 then + local data = json.decode(http_response:payload()) + + if not(data and data.body and data.body.user and data.body.home and data.body.gid and data.body.uid and data.body.quota and data.body.mailCrypt and data.body.mailCryptPublicKey) then + request:log_error(log_msg['http-ok-malformed']) + return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "" + end + + local attributes = {} + attributes["user"] = data.body.user + attributes["home"] = data.body.home + attributes["gid"] = data.body.gid + attributes["uid"] = data.body.uid + + if data.body.quota ~= "" then + attributes["userdb_quota_rule"] = data.body.quota + end + -- Only return mailcrypt attributes if mailcrypt is enabled for user: + if data.body.mailCrypt == 2 then + attributes["userdb_mail_crypt_global_public_key"] = data.body.mailCryptPublicKey + attributes["userdb_mail_crypt_save_version"] = data.body.mailCrypt + end + request:log_info(log_msg['http-ok'] .. http_response:status()) + return dovecot.auth.USERDB_RESULT_OK, attributes + end + + if http_response:status() == 404 then + request:log_warning(log_msg['http-failed'] .. http_response:status()) + return dovecot.auth.USERDB_RESULT_USER_UNKNOWN, "" + end + + request:log_error(log_msg['http-unexpected'].. http_response:status()) + return dovecot.auth.USERDB_RESULT_INTERNAL_FAILURE, "" +end + +function auth_password_verify(request, password) + local http_request = http_client:request { + url = api_url .. "/" .. request.original_user; + method = "POST" + } + http_request:add_header("Content-Type","application/json") + http_request:add_header("Authorization","Bearer " .. env_userli_token) + http_request:set_payload(string.format('{"password": "%s"}', password)) + local http_response = http_request:submit() + + if http_response:status() == 200 then + local data = json.decode(http_response:payload()) + + -- mailCryptPrivateKey may be empty, but cannot be nil + if not(data and data.body and data.body.mailCrypt and data.body.mailCryptPrivateKey) then + request:log_error(log_msg['http-ok-malformed']) + return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "" + end + + local attributes = {} + -- Only return mailcrypt attributes if mailcrypt is enabled for user: + if data.body.mailCrypt == 2 then + attributes["userdb_mail_crypt_save_version"] = data.body.mailCrypt + attributes["userdb_mail_crypt_global_private_key"] = data.body.mailCryptPrivateKey + end + return dovecot.auth.PASSDB_RESULT_OK, attributes + end + + if http_response:status() == 401 then + request:log_warning(log_msg['http-failed'] .. http_response:status()) + return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "" + end + + if http_response:status() == 404 then + request:log_warning(log_msg['http-failed'] .. http_response:status()) + return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "" + end + + if http_response:status() == 500 then + local msg = log_msg['http-failed'] .. http_response:status() + local data = json.decode(http_response:payload()) + local err = data['error'] + if err then + msg = msg .. ", Upstream-error: " .. err + end + request:log_error(msg) + return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "" + end + + request:log_error(log_msg['http-unexpected'] .. http_response:status()) + return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "" +end + diff --git a/config/packages/security.yaml b/config/packages/security.yaml index e1157465..e343bd47 100755 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -19,6 +19,11 @@ security: users: - identifier: keycloak roles: ['ROLE_KEYCLOAK'] + dovecot: + memory: + users: + - identifier: dovecot + roles: ['ROLE_DOVECOT'] role_hierarchy: # User @@ -122,6 +127,12 @@ security: provider: user http_basic: realm: Roundcube API + dovecot: + pattern: ^/api/dovecot + stateless: true + provider: dovecot + access_token: + token_handler: App\Security\DovecotAccessTokenHandler main: pattern: ^/ provider: user @@ -175,3 +186,9 @@ security: allow_if: "'%env(ROUNDCUBE_API_ENABLED)%' == 'true' and is_granted('ROLE_USER')", } - { path: "^/api/roundcube", roles: ROLE_NO_ACCESS } + - { + path: "^/api/dovecot", + ips: "%env(DOVECOT_API_IP_ALLOWLIST)%", + allow_if: "'%env(DOVECOT_API_ENABLED)%' == 'true' and is_granted('ROLE_DOVECOT')", + } + - { path: "^/api/dovecot", roles: ROLE_NO_ACCESS } diff --git a/config/services.yaml b/config/services.yaml index 5e6c1f9d..520ed999 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -79,6 +79,13 @@ services: arguments: $appUrl: "%env(APP_URL)%" + App\Controller\DovecotController: + arguments: + $mailCryptEnv: "%env(MAIL_CRYPT)%" + $mailLocation: "%env(DOVECOT_MAIL_LOCATION)%" + $mailUid: "%env(DOVECOT_MAIL_UID)%" + $mailGid: "%env(DOVECOT_MAIL_GID)%" + App\EventListener\: resource: '../src/EventListener/*' tags: @@ -168,6 +175,10 @@ services: arguments: $keycloakApiAccessToken: "%env(KEYCLOAK_API_ACCESS_TOKEN)%" + App\Security\DovecotAccessTokenHandler: + arguments: + $dovecotApiAccessToken: "%env(DOVECOT_API_ACCESS_TOKEN)%" + App\Sender\WelcomeMessageSender: public: true diff --git a/src/Controller/DovecotController.php b/src/Controller/DovecotController.php new file mode 100644 index 00000000..037e7f99 --- /dev/null +++ b/src/Controller/DovecotController.php @@ -0,0 +1,126 @@ +mailCrypt = MailCrypt::from($this->mailCryptEnv); + } + + #[Route('/api/dovecot/status', name: 'api_dovecot_status', methods: ['GET'], stateless: true)] + public function status(): JsonResponse + { + return $this->json([ + 'message' => self::MESSAGE_SUCCESS, + ], JsonResponse::HTTP_OK); + } + + #[Route('/api/dovecot/{email}', name: 'api_dovecot_user_lookup', methods: ['GET'], stateless: true)] + public function lookup( + #[MapEntity(mapping: ['email' => 'email'])] User $user, + ): JsonResponse { + // Spammers are not excluded from lookup + if (null === $user || $user->isDeleted()) { + return $this->json(['message' => self::MESSAGE_USER_NOT_FOUND], JsonResponse::HTTP_NOT_FOUND); + } + + if ( + $this->mailCrypt->isAtLeast(MailCrypt::ENABLED_OPTIONAL) && + $user->hasMailCrypt() && + $user->hasMailCryptPublicKey() + ) { + $mailCryptReported = 2; + } else { + $mailCryptReported = 0; + } + [$username, $domain] = explode('@', $user->getEmail()); + + return $this->json([ + 'message' => self::MESSAGE_SUCCESS, + 'body' => [ + 'user' => $user->getEmail(), + 'home' => $this->mailLocation.DIRECTORY_SEPARATOR.$domain.DIRECTORY_SEPARATOR.$username, + 'mailCrypt' => $mailCryptReported, + 'mailCryptPublicKey' => $user->getMailCryptPublicKey() ?? "", + 'gid' => $this->mailGid, + 'uid' => $this->mailUid, + 'quota' => $user->getQuota() ?? "", + ] + ], JsonResponse::HTTP_OK); + } + + #[Route('/api/dovecot/{email}', name: 'api_dovecot_user_authenticate', methods: ['POST'], stateless: true)] + public function authenticate( + #[MapEntity(mapping: ['email' => 'email'])] User $user, + #[MapRequestPayload] DovecotPassdbDto $request, + ): JsonResponse { + // Spammers are excluded from login + if (null === $user || $user->isDeleted() || $user->hasRole(Roles::SPAM)) { + return $this->json(['message' => self::MESSAGE_USER_NOT_FOUND], JsonResponse::HTTP_NOT_FOUND); + } + + if (null === $this->authHandler->authenticate($user, $request->getPassword())) { + return $this->json(['message' => self::MESSAGE_AUTHENTICATION_FAILED], JsonResponse::HTTP_UNAUTHORIZED); + } + + // If mailCrypt is enforced for all users, optionally create mailCrypt keypair for user + if ( + $this->mailCrypt === MailCrypt::ENABLED_ENFORCE_ALL_USERS && + false === $user->getMailCrypt() && + null === $user->getMailCryptPublicKey() + ) { + try { + $this->mailCryptKeyHandler->create($user, $request->getPassword(),true); + } catch (Exception $exception) { + return $this->json(['error' => $exception->getMessage()], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); + } + } + + // If mailCrypt is enabled and enabled for user, derive mailCryptPrivateKey + if ($this->mailCrypt->isAtLeast(MailCrypt::ENABLED_OPTIONAL) && $user->hasMailCrypt()) { + try { + $privateKey = $this->mailCryptKeyHandler->decrypt($user, $request->getPassword()); + } catch (Exception $exception) { + return $this->json(['error' => $exception->getMessage()], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); + } + $mailCryptReported = 2; + } else { + $mailCryptReported = 0; + } + + return $this->json([ + 'message' => self::MESSAGE_SUCCESS, + 'body' => [ + 'mailCrypt' => $mailCryptReported, + 'mailCryptPrivateKey' => $privateKey ?? "", + ] + ], JsonResponse::HTTP_OK); + } +} diff --git a/src/Dto/DovecotPassdbDto.php b/src/Dto/DovecotPassdbDto.php new file mode 100644 index 00000000..233c8cbc --- /dev/null +++ b/src/Dto/DovecotPassdbDto.php @@ -0,0 +1,21 @@ +password; + } + + public function setPassword(string $password): void + { + $this->password = $password; + } +} diff --git a/src/Enum/MailCrypt.php b/src/Enum/MailCrypt.php new file mode 100644 index 00000000..fab9d4a4 --- /dev/null +++ b/src/Enum/MailCrypt.php @@ -0,0 +1,27 @@ + self::DISABLED, + '1' => self::ENABLED_OPTIONAL, + '2' => self::ENABLED_ENFORCE_NEW_USERS, + '3' => self::ENABLED_ENFORCE_ALL_USERS, + default => throw new \InvalidArgumentException("Invalid MailCrypt value: $value"), + }; + } + + public function isAtLeast(self $other): bool + { + return $this->value >= $other->value; + } +} diff --git a/src/Security/DovecotAccessTokenHandler.php b/src/Security/DovecotAccessTokenHandler.php new file mode 100644 index 00000000..83009275 --- /dev/null +++ b/src/Security/DovecotAccessTokenHandler.php @@ -0,0 +1,21 @@ +dovecotApiAccessToken) { + throw new BadCredentialsException('Invalid access token'); + } + + return new UserBadge('dovecot'); + } +} diff --git a/tests/Controller/DovecotControllerTest.php b/tests/Controller/DovecotControllerTest.php new file mode 100644 index 00000000..65b371e8 --- /dev/null +++ b/tests/Controller/DovecotControllerTest.php @@ -0,0 +1,130 @@ + 'Bearer wrong', + ]); + $client->request('GET', '/api/dovecot/user@example.org'); + + self::assertResponseStatusCodeSame(401); + } + + public function testPassdbUser(): void + { + $client = static::createClient([], [ + 'HTTP_Authorization' => 'Bearer insecure', + ]); + $client->request('POST', '/api/dovecot/support@example.org', ['password' => 'password']); + + self::assertResponseStatusCodeSame(200); + } + + public function testPassdbUserWrongPassword(): void + { + $client = static::createClient([], [ + 'HTTP_Authorization' => 'Bearer insecure', + ]); + $client->request('POST', '/api/dovecot/support@example.org', ['password' => 'wrong']); + + self::assertResponseStatusCodeSame(401); + } + + public function testPassdbNonexistentUser(): void + { + $client = static::createClient([], [ + 'HTTP_Authorization' => 'Bearer insecure', + ]); + $client->request('POST', '/api/dovecot/nonexistent@example.org', ['password' => 'password']); + + self::assertResponseStatusCodeSame(404); + } + + public function testPassdbSpamUser(): void + { + $client = static::createClient([], [ + 'HTTP_Authorization' => 'Bearer insecure', + ]); + $client->request('POST', '/api/dovecot/spam@example.org', ['password' => 'password']); + + self::assertResponseStatusCodeSame(404); + } + + public function testPassdbMailCrypt(): void + { + $client = static::createClient([], [ + 'HTTP_Authorization' => 'Bearer insecure', + ]); + $client->request('POST', '/api/dovecot/mailcrypt@example.org', ['password' => 'password']); + + self::assertResponseStatusCodeSame(200); + $data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertNotNull($data['body']['mailCryptPrivateKey']); + self::assertNotEquals($data['body']['mailCryptPrivateKey'], ""); + } + + public function testUserdbUser(): void + { + $client = static::createClient([], [ + 'HTTP_Authorization' => 'Bearer insecure', + ]); + $client->request('GET', '/api/dovecot/user@example.org'); + + self::assertResponseStatusCodeSame(200); + + $data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertEquals($data['message'], 'success'); + self::assertEquals($data['body']['user'], 'user@example.org'); + self::assertEquals($data['body']['mailCrypt'], 0); + self::assertEquals($data['body']['mailCryptPublicKey'], ""); + self::assertIsInt($data['body']['gid']); + self::assertIsInt($data['body']['uid']); + self::assertNotEquals($data['body']['home'], ''); + + } + + public function testUserdbMailcrypt(): void + { + $client = static::createClient([], [ + 'HTTP_Authorization' => 'Bearer insecure', + ]); + $client->request('GET', '/api/dovecot/mailcrypt@example.org'); + + self::assertResponseStatusCodeSame(200); + + $data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertEquals($data['message'], 'success'); + self::assertEquals($data['body']['email'], 'mailcrypt@example.org'); + self::assertEquals($data['body']['domain'], 'example.org'); + self::assertEquals($data['body']['mailCrypt'], 2); + self::assertNotNull($data['body']['mailCryptPublicKey']); + } + + public function testUserdbNonexistentUser(): void + { + $client = static::createClient([], [ + 'HTTP_Authorization' => 'Bearer insecure', + ]); + $client->request('GET', '/api/dovecot/nonexistent@example.org'); + + self::assertResponseStatusCodeSame(404); + } + + // Exclude spam users from login, but not from lookup + public function testUserdbSpamUser(): void + { + $client = static::createClient([], [ + 'HTTP_Authorization' => 'Bearer insecure', + ]); + $client->request('GET', '/api/dovecot/spam@example.org'); + + self::assertResponseStatusCodeSame(200); + } +}