-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add dovecot API and lua authentication script
- Loading branch information
Showing
10 changed files
with
535 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.