Skip to content

Commit

Permalink
Add dovecot API and lua authentication script
Browse files Browse the repository at this point in the history
  • Loading branch information
y3n4 committed Oct 18, 2024
1 parent d21c53d commit cf9b408
Show file tree
Hide file tree
Showing 10 changed files with 535 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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"
171 changes: 171 additions & 0 deletions checkpasswd.lua
Original file line number Diff line number Diff line change
@@ -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

17 changes: 17 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ security:
users:
- identifier: keycloak
roles: ['ROLE_KEYCLOAK']
dovecot:
memory:
users:
- identifier: dovecot
roles: ['ROLE_DOVECOT']

role_hierarchy:
# User
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
11 changes: 11 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
126 changes: 126 additions & 0 deletions src/Controller/DovecotController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

namespace App\Controller;

use App\Dto\DovecotPassdbDto;
use App\Entity\User;
use App\Enum\MailCrypt;
use App\Enum\Roles;
use App\Handler\MailCryptKeyHandler;
use App\Handler\UserAuthenticationHandler;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\Routing\Annotation\Route;

class DovecotController extends AbstractController
{
const MESSAGE_SUCCESS = 'success';
const MESSAGE_AUTHENTICATION_FAILED = 'authentication failed';
const MESSAGE_USER_NOT_FOUND = 'user not found';

private readonly MailCrypt $mailCrypt;

public function __construct(
private readonly MailCryptKeyHandler $mailCryptKeyHandler,
private readonly UserAuthenticationHandler $authHandler,
private readonly string $mailLocation,
private readonly int $mailCryptEnv,
private readonly int $mailUid,
private readonly int $mailGid,
) {
$this->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);
}
}
Loading

0 comments on commit cf9b408

Please sign in to comment.