mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-28 20:23:26 +00:00
[Web] Rework API keys: multiple read-only/read-write keys, service keys from mailcow.conf
This commit is contained in:
21 files changed
+942
-335
No files matched your search
@@ -2054,128 +2054,6 @@ function verify_tfa_login($username, $_data) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
function admin_api($access, $action, $data = null) {
|
||||
global $pdo;
|
||||
if ($_SESSION['mailcow_cc_role'] != "admin") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if ($access !== "ro" && $access !== "rw") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__),
|
||||
'msg' => 'invalid access type'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if ($action == "edit") {
|
||||
$active = (!empty($data['active'])) ? 1 : 0;
|
||||
$skip_ip_check = (isset($data['skip_ip_check'])) ? 1 : 0;
|
||||
$allow_from = array();
|
||||
if (isset($data['allow_from'])) {
|
||||
$allow_from = array_map('trim', preg_split( "/( |,|;|\n)/", $data['allow_from']));
|
||||
}
|
||||
foreach ($allow_from as $key => $val) {
|
||||
if (empty($val)) {
|
||||
unset($allow_from[$key]);
|
||||
continue;
|
||||
}
|
||||
if (valid_network($val) !== true) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'warning',
|
||||
'log' => array(__FUNCTION__, $data),
|
||||
'msg' => array('ip_invalid', htmlspecialchars($allow_from[$key]))
|
||||
);
|
||||
unset($allow_from[$key]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$allow_from = implode(',', array_unique(array_filter($allow_from)));
|
||||
if (empty($allow_from) && $skip_ip_check == 0) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $data),
|
||||
'msg' => 'ip_list_empty'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$api_key = implode('-', array(
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3)))
|
||||
));
|
||||
$stmt = $pdo->query("SELECT `api_key` FROM `api` WHERE `access` = '" . $access . "'");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
if (empty($num_results)) {
|
||||
$stmt = $pdo->prepare("INSERT INTO `api` (`api_key`, `skip_ip_check`, `active`, `allow_from`, `access`)
|
||||
VALUES (:api_key, :skip_ip_check, :active, :allow_from, :access);");
|
||||
$stmt->execute(array(
|
||||
':api_key' => $api_key,
|
||||
':skip_ip_check' => $skip_ip_check,
|
||||
':active' => $active,
|
||||
':allow_from' => $allow_from,
|
||||
':access' => $access
|
||||
));
|
||||
}
|
||||
else {
|
||||
if ($skip_ip_check == 0) {
|
||||
$stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check,
|
||||
`active` = :active,
|
||||
`allow_from` = :allow_from
|
||||
WHERE `access` = :access;");
|
||||
$stmt->execute(array(
|
||||
':active' => $active,
|
||||
':skip_ip_check' => $skip_ip_check,
|
||||
':allow_from' => $allow_from,
|
||||
':access' => $access
|
||||
));
|
||||
}
|
||||
else {
|
||||
$stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check,
|
||||
`active` = :active
|
||||
WHERE `access` = :access;");
|
||||
$stmt->execute(array(
|
||||
':active' => $active,
|
||||
':skip_ip_check' => $skip_ip_check,
|
||||
':access' => $access
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($action == "regen_key") {
|
||||
$api_key = implode('-', array(
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3)))
|
||||
));
|
||||
$stmt = $pdo->prepare("UPDATE `api` SET `api_key` = :api_key WHERE `access` = :access");
|
||||
$stmt->execute(array(
|
||||
':api_key' => $api_key,
|
||||
':access' => $access
|
||||
));
|
||||
}
|
||||
elseif ($action == "get") {
|
||||
$stmt = $pdo->query("SELECT * FROM `api` WHERE `access` = '" . $access . "'");
|
||||
$apidata = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($apidata !== false) {
|
||||
$apidata['allow_from'] = str_replace(',', PHP_EOL, $apidata['allow_from']);
|
||||
}
|
||||
return $apidata;
|
||||
}
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $data),
|
||||
'msg' => 'admin_api_modified'
|
||||
);
|
||||
}
|
||||
function license($action, $data = null) {
|
||||
global $pdo;
|
||||
global $redis;
|
||||
|
||||
@@ -4,7 +4,7 @@ function init_db_schema()
|
||||
try {
|
||||
global $pdo;
|
||||
|
||||
$db_version = "16102026_1200";
|
||||
$db_version = "22102026_1200";
|
||||
|
||||
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
@@ -204,18 +204,25 @@ function init_db_schema()
|
||||
),
|
||||
"api" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"api_key" => "VARCHAR(255) NOT NULL",
|
||||
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"allow_from" => "VARCHAR(512) NOT NULL",
|
||||
"skip_ip_check" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE NOW(0)",
|
||||
"access" => "ENUM('ro', 'rw') NOT NULL DEFAULT 'rw'",
|
||||
// 1 = defined in mailcow.conf, read-only in UI and API
|
||||
"service" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("api_key")
|
||||
"" => array("id")
|
||||
),
|
||||
"unique" => array(
|
||||
"api_key" => array("api_key")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
@@ -1855,4 +1862,13 @@ if (php_sapi_name() == "cli") {
|
||||
// Dunno
|
||||
}
|
||||
init_db_schema();
|
||||
// Service API keys from mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM)
|
||||
try {
|
||||
$api_keys = \Mailcow\ApiKey\ApiKeyModule::create($pdo, \Mailcow\Core\Context::system())->keys;
|
||||
foreach ($api_keys->syncServiceKeys(getenv()) as $warning) {
|
||||
echo "API service keys: " . $warning . PHP_EOL;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo "API service keys: sync failed: " . $e->getMessage() . PHP_EOL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey;
|
||||
|
||||
use Mailcow\ApiKey\Controller\ApiKeyApiController;
|
||||
use Mailcow\ApiKey\Repository\ApiKeyRepository;
|
||||
use Mailcow\ApiKey\Service\ApiKeyService;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Composition root of the API key module.
|
||||
*
|
||||
* process_add_return(ApiKeyModule::fromGlobals()->api->addKey($attr));
|
||||
* ApiKeyModule::create($pdo, Context::system())->keys->syncServiceKeys(getenv()); // php-fpm start
|
||||
*/
|
||||
class ApiKeyModule
|
||||
{
|
||||
/** Request keys whose values never reach a log */
|
||||
private const SECRET_KEYS = ['api_key'];
|
||||
|
||||
public function __construct(public readonly ApiKeyService $keys, public readonly ApiKeyApiController $api)
|
||||
{
|
||||
}
|
||||
|
||||
public static function create(PDO $pdo, Context $context): self
|
||||
{
|
||||
$keys = new ApiKeyService(new ApiKeyRepository($pdo), $context);
|
||||
return new self($keys, new ApiKeyApiController($keys, new ApiResponse(self::SECRET_KEYS)));
|
||||
}
|
||||
|
||||
/** Legacy entry point for procedural callers: reads $pdo and $_SESSION; $context overrides the session caller */
|
||||
public static function fromGlobals(?Context $context = null): self
|
||||
{
|
||||
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey\Controller;
|
||||
|
||||
use Mailcow\ApiKey\Model\ApiKey;
|
||||
use Mailcow\ApiKey\Service\ApiKeyService;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php and edit.php (routes `api-key`).
|
||||
*
|
||||
* Write methods return what json_api passes to process_*_return() and write their messages via ApiResponse.
|
||||
* Messages are logged as ['api', action, 'key', data].
|
||||
*/
|
||||
class ApiKeyApiController
|
||||
{
|
||||
public function __construct(private ApiKeyService $keys, private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** POST add/api-key */
|
||||
public function addKey(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', $attr,
|
||||
fn(array $log): bool => $this->response->success(['api_key_added', self::label($this->keys->create($attr))], $log));
|
||||
}
|
||||
|
||||
/** POST edit/api-key: one message per id */
|
||||
public function editKeys(?array $items, array $attr): bool
|
||||
{
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool {
|
||||
$this->keys->requireAdmin();
|
||||
return $this->batch((array)($data['id'] ?? []), $log,
|
||||
fn(int $id): array => ['api_key_modified', self::label($this->keys->update($id, $attr))]);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST delete/api-key: one message per id */
|
||||
public function deleteKeys(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$this->keys->requireAdmin();
|
||||
return $this->batch($items, $log, fn(int $id): array => ['api_key_deleted', self::label($this->keys->delete($id))]);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET api-key/all */
|
||||
public function getKeys(): array
|
||||
{
|
||||
return array_map(fn(ApiKey $key): array => $key->toArray(), $this->keys->all());
|
||||
}
|
||||
|
||||
/** GET api-key/<id>, also edit.php */
|
||||
public function getKey(mixed $id): array
|
||||
{
|
||||
$id = $this->toId($id);
|
||||
$key = ($id === null) ? null : $this->keys->get($id);
|
||||
return ($key === null) ? [] : $key->toArray();
|
||||
}
|
||||
|
||||
/** Name of a key in messages: its description, else its id */
|
||||
private static function label(ApiKey $key): string
|
||||
{
|
||||
return htmlspecialchars($key->description !== '' ? $key->description : (string)$key->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one write call: $work gets the log entry and returns the result for process_*_return().
|
||||
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
|
||||
*/
|
||||
private function handle(string $action, mixed $data, callable $work): bool
|
||||
{
|
||||
$log = ['api', $action, 'key', $data];
|
||||
try {
|
||||
$result = $work($log);
|
||||
} catch (UserError $e) {
|
||||
$result = $this->response->error($e, $log);
|
||||
} catch (PDOException $e) {
|
||||
$result = $this->response->danger(['mysql_error', $e->getMessage()], $log);
|
||||
}
|
||||
$this->response->flush();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** One message per id: the success message returned by $work(id), or the UserError */
|
||||
private function batch(array $ids, array $log, callable $work): bool
|
||||
{
|
||||
foreach ($ids as $id) {
|
||||
try {
|
||||
$this->response->success($work($this->requireId($id)), $log);
|
||||
} catch (UserError $e) {
|
||||
$this->response->error($e, $log);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Integer id from request input ("12", 12); null for anything else */
|
||||
private function toId(mixed $id): ?int
|
||||
{
|
||||
return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null;
|
||||
}
|
||||
|
||||
/** @throws AccessDenied for ids that cannot exist */
|
||||
private function requireId(mixed $id): int
|
||||
{
|
||||
return $this->toId($id) ?? throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey\Model;
|
||||
|
||||
/**
|
||||
* An API key (table `api`), sent by clients in the X-API-Key header (see sessions.inc.php).
|
||||
*
|
||||
* Unlike other secrets the key is part of toArray(): admins copy it from the UI. ApiKeyService limits
|
||||
* read access to admins (not with a read-only API key).
|
||||
*/
|
||||
class ApiKey
|
||||
{
|
||||
public const ACCESS_RO = 'ro';
|
||||
public const ACCESS_RW = 'rw';
|
||||
|
||||
public ?int $id = null;
|
||||
public string $key = '';
|
||||
public string $description = '';
|
||||
public string $access = self::ACCESS_RO;
|
||||
/** @var string[] IPs and CIDR networks the key may be used from */
|
||||
public array $allowFrom = [];
|
||||
public int $skipIpCheck = 0;
|
||||
/** 1 = service key from mailcow.conf: shown, but neither editable nor deletable in UI and API */
|
||||
public int $service = 0;
|
||||
public int $active = 1;
|
||||
public ?string $created = null;
|
||||
public ?string $modified = null;
|
||||
|
||||
/** Builds a key from an `api` row (column names as in the table) */
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$key = new self();
|
||||
$key->id = isset($row['id']) ? (int)$row['id'] : null;
|
||||
$key->key = (string)($row['api_key'] ?? '');
|
||||
$key->description = (string)($row['description'] ?? '');
|
||||
$key->access = (string)($row['access'] ?? self::ACCESS_RO);
|
||||
$key->allowFrom = array_values(array_filter(array_map('trim', explode(',', (string)($row['allow_from'] ?? ''))), 'strlen'));
|
||||
$key->skipIpCheck = (int)($row['skip_ip_check'] ?? 0);
|
||||
$key->service = (int)($row['service'] ?? 0);
|
||||
$key->active = (int)($row['active'] ?? 1);
|
||||
$key->created = isset($row['created']) ? (string)$row['created'] : null;
|
||||
$key->modified = isset($row['modified']) ? (string)$row['modified'] : null;
|
||||
return $key;
|
||||
}
|
||||
|
||||
/** API representation (GET api-key) */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'description' => $this->description,
|
||||
'access' => $this->access,
|
||||
'api_key' => $this->key,
|
||||
'allow_from' => $this->allowFrom,
|
||||
'skip_ip_check' => $this->skipIpCheck,
|
||||
'service' => $this->service,
|
||||
'active' => $this->active,
|
||||
'created' => $this->created,
|
||||
'modified' => $this->modified,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey\Repository;
|
||||
|
||||
use Mailcow\ApiKey\Model\ApiKey;
|
||||
use Mailcow\Core\Repository;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* SQL for the API keys (table `api`).
|
||||
*/
|
||||
class ApiKeyRepository extends Repository
|
||||
{
|
||||
/** @return ApiKey[] service keys first, then by id */
|
||||
public function all(): array
|
||||
{
|
||||
return array_map([ApiKey::class, 'fromRow'],
|
||||
$this->query("SELECT * FROM `api` ORDER BY `service` DESC, `id`")->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function find(int $id): ?ApiKey
|
||||
{
|
||||
$row = $this->query("SELECT * FROM `api` WHERE `id` = :id", ['id' => $id])->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ? ApiKey::fromRow($row) : null;
|
||||
}
|
||||
|
||||
/** Inserts the key and returns the new id */
|
||||
public function insert(ApiKey $key): int
|
||||
{
|
||||
$key->id = $this->insertRow('api', $this->values($key) + ['service' => $key->service]);
|
||||
return $key->id;
|
||||
}
|
||||
|
||||
public function update(ApiKey $key): void
|
||||
{
|
||||
$this->updateRow('api', (int)$key->id, $this->values($key));
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->query("DELETE FROM `api` WHERE `id` = :id", ['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the service keys match $keys in one transaction: other service keys are deleted,
|
||||
* an existing row with the same key (also a UI key) becomes the service key.
|
||||
*
|
||||
* @param ApiKey[] $keys
|
||||
*/
|
||||
public function syncService(array $keys): void
|
||||
{
|
||||
$this->transaction(function () use ($keys): void {
|
||||
$params = [];
|
||||
foreach (array_values($keys) as $i => $key) {
|
||||
$params['k' . $i] = $key->key;
|
||||
}
|
||||
$keep = empty($params) ? '' : " AND `api_key` NOT IN (:" . implode(', :', array_keys($params)) . ")";
|
||||
$this->query("DELETE FROM `api` WHERE `service` = '1'" . $keep, $params);
|
||||
// No INSERT ... ON DUPLICATE KEY UPDATE: it uses up an id on every php-fpm start
|
||||
foreach ($keys as $key) {
|
||||
$id = $this->query("SELECT `id` FROM `api` WHERE `api_key` = :api_key", ['api_key' => $key->key])->fetchColumn();
|
||||
if ($id === false) {
|
||||
$this->insertRow('api', $this->values($key) + ['service' => 1]);
|
||||
} else {
|
||||
$this->updateRow('api', (int)$id, $this->values($key) + ['service' => 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function values(ApiKey $key): array
|
||||
{
|
||||
return [
|
||||
'api_key' => $key->key,
|
||||
'description' => $key->description,
|
||||
'allow_from' => implode(',', $key->allowFrom),
|
||||
'skip_ip_check' => $key->skipIpCheck,
|
||||
'access' => $key->access,
|
||||
'active' => $key->active,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey\Service;
|
||||
|
||||
use Mailcow\ApiKey\Model\ApiKey;
|
||||
use Mailcow\ApiKey\Repository\ApiKeyRepository;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
|
||||
/**
|
||||
* Use cases and rules for API keys.
|
||||
*
|
||||
* Admins only. A read-only API key may not even read the keys, since they include read-write keys.
|
||||
* Service keys are defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) and mirrored
|
||||
* into the table on every php-fpm start (syncServiceKeys()). UI and API show them, but cannot change them.
|
||||
*/
|
||||
class ApiKeyService
|
||||
{
|
||||
/** mailcow.conf variable => access of the service key it defines */
|
||||
public const SERVICE_KEYS = ['API_KEY' => ApiKey::ACCESS_RW, 'API_KEY_READ_ONLY' => ApiKey::ACCESS_RO];
|
||||
|
||||
public function __construct(private ApiKeyRepository $repo, private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws AccessDenied unless an admin (login or read-write API key) */
|
||||
public function requireAdmin(): void
|
||||
{
|
||||
if (!$this->context->isAdmin() || $this->context->isReadOnlyApi()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return ApiKey[] all keys, service keys first; [] for other callers */
|
||||
public function all(): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->all() : [];
|
||||
}
|
||||
|
||||
public function get(int $id): ?ApiKey
|
||||
{
|
||||
return $this->canRead() ? $this->repo->find($id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a key with a random value; `access` is required.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function create(array $input): ApiKey
|
||||
{
|
||||
$this->requireAdmin();
|
||||
$key = new ApiKey();
|
||||
$key->key = self::generate();
|
||||
$this->apply($key, $input);
|
||||
$this->repo->insert($key);
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a key; attributes missing in $input keep their value, `regen_key` issues a new key value.
|
||||
*
|
||||
* @throws UserError access_denied, api_key_service_locked, validation errors
|
||||
*/
|
||||
public function update(int $id, array $input): ApiKey
|
||||
{
|
||||
$this->requireAdmin();
|
||||
$key = $this->editable($id);
|
||||
$this->apply($key, $input);
|
||||
if (self::flag($input['regen_key'] ?? 0) === 1) {
|
||||
$key->key = self::generate();
|
||||
}
|
||||
$this->repo->update($key);
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a key and returns it.
|
||||
*
|
||||
* @throws UserError access_denied, api_key_service_locked
|
||||
*/
|
||||
public function delete(int $id): ApiKey
|
||||
{
|
||||
$this->requireAdmin();
|
||||
$key = $this->editable($id);
|
||||
$this->repo->delete($id);
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the service keys of mailcow.conf into the table. Runs on php-fpm start (init_db CLI, master only),
|
||||
* so there is no permission check. $env holds the container environment (getenv()).
|
||||
* Without a valid API_ALLOW_FROM no service key is active, like before. Service keys that were removed from
|
||||
* mailcow.conf are deleted; a UI key with the same value becomes the service key.
|
||||
*
|
||||
* @return string[] warnings for the container log
|
||||
*/
|
||||
public function syncServiceKeys(array $env): array
|
||||
{
|
||||
$warnings = [];
|
||||
$allowFrom = [];
|
||||
foreach (self::split($env['API_ALLOW_FROM'] ?? '') as $network) {
|
||||
// 'invalid' is the docker-compose default for an unset variable
|
||||
if ($network === 'invalid') {
|
||||
continue;
|
||||
}
|
||||
if (self::isNetwork($network)) {
|
||||
$allowFrom[] = $network;
|
||||
} else {
|
||||
$warnings[] = 'API_ALLOW_FROM: skipped invalid IP/network ' . $network;
|
||||
}
|
||||
}
|
||||
$keys = [];
|
||||
foreach (self::SERVICE_KEYS as $var => $access) {
|
||||
$value = trim((string)($env[$var] ?? ''));
|
||||
if ($value === '' || $value === 'invalid') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^[a-zA-Z0-9-]+$/', $value)) {
|
||||
$warnings[] = $var . ': invalid characters (allowed: a-z, A-Z, 0-9, -), key ignored';
|
||||
continue;
|
||||
}
|
||||
if (empty($allowFrom)) {
|
||||
$warnings[] = $var . ': API_ALLOW_FROM contains no valid IP/network, key ignored';
|
||||
continue;
|
||||
}
|
||||
if (isset($keys[$value])) {
|
||||
$warnings[] = $var . ': same value as ' . $keys[$value]->description . ', key ignored';
|
||||
continue;
|
||||
}
|
||||
$key = new ApiKey();
|
||||
$key->key = $value;
|
||||
$key->description = $var;
|
||||
$key->access = $access;
|
||||
$key->allowFrom = $allowFrom;
|
||||
$key->service = 1;
|
||||
$keys[$value] = $key;
|
||||
}
|
||||
$this->repo->syncService(array_values($keys));
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
private function canRead(): bool
|
||||
{
|
||||
return $this->context->isAdmin() && !$this->context->isReadOnlyApi();
|
||||
}
|
||||
|
||||
/** @throws AccessDenied when missing, UserError api_key_service_locked for service keys */
|
||||
private function editable(int $id): ApiKey
|
||||
{
|
||||
$key = $this->repo->find($id) ?? throw new AccessDenied();
|
||||
if ($key->service === 1) {
|
||||
throw new UserError('api_key_service_locked', [$key->description]);
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/edit rules: attributes missing in $input keep the current value, a new key starts with the
|
||||
* model defaults. Without skip_ip_check the key needs at least one IP/network.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
private function apply(ApiKey $key, array $input): void
|
||||
{
|
||||
if (isset($input['description'])) {
|
||||
$description = Input::text($input['description']);
|
||||
if ($description === null || mb_strlen($description) > 255) {
|
||||
throw new UserError('api_key_description_invalid');
|
||||
}
|
||||
$key->description = $description;
|
||||
}
|
||||
if (isset($input['access']) || $key->id === null) {
|
||||
$access = $input['access'] ?? null;
|
||||
if (!in_array($access, [ApiKey::ACCESS_RO, ApiKey::ACCESS_RW], true)) {
|
||||
throw new UserError('api_key_access_invalid');
|
||||
}
|
||||
$key->access = $access;
|
||||
}
|
||||
if (isset($input['allow_from'])) {
|
||||
$key->allowFrom = self::networks($input['allow_from']);
|
||||
}
|
||||
if (isset($input['skip_ip_check'])) {
|
||||
$key->skipIpCheck = self::flag($input['skip_ip_check']);
|
||||
}
|
||||
if (isset($input['active'])) {
|
||||
$key->active = self::flag($input['active']);
|
||||
}
|
||||
if ($key->skipIpCheck === 0 && empty($key->allowFrom)) {
|
||||
throw new UserError('ip_list_empty');
|
||||
}
|
||||
}
|
||||
|
||||
/** New random key in the format of the legacy UI: five groups of six hex digits */
|
||||
private static function generate(): string
|
||||
{
|
||||
return implode('-', array_map(fn(): string => strtoupper(bin2hex(random_bytes(3))), range(1, 5)));
|
||||
}
|
||||
|
||||
/** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */
|
||||
private static function flag(mixed $value): int
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = end($value);
|
||||
}
|
||||
return filter_var($value, FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] validated IPs/networks
|
||||
* @throws UserError api_key_network_invalid for the first invalid entry
|
||||
*/
|
||||
private static function networks(mixed $value): array
|
||||
{
|
||||
$networks = self::split($value);
|
||||
foreach ($networks as $network) {
|
||||
if (!self::isNetwork($network)) {
|
||||
throw new UserError('api_key_network_invalid', [htmlspecialchars($network)]);
|
||||
}
|
||||
}
|
||||
return $networks;
|
||||
}
|
||||
|
||||
/** @return string[] unique entries of a list: array or string separated by spaces, commas, semicolons or newlines */
|
||||
private static function split(mixed $value): array
|
||||
{
|
||||
$values = is_array($value) ? $value : preg_split('/[\s,;]+/', is_scalar($value) ? (string)$value : '');
|
||||
$values = array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values);
|
||||
return array_values(array_unique(array_filter($values, 'strlen')));
|
||||
}
|
||||
|
||||
/** IPv4/IPv6 address or CIDR network */
|
||||
private static function isNetwork(string $value): bool
|
||||
{
|
||||
[$ip, $prefix] = array_pad(explode('/', $value, 2), 2, null);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
if ($prefix === null) {
|
||||
return true;
|
||||
}
|
||||
$max = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 32 : 128;
|
||||
return ctype_digit($prefix) && (int)$prefix <= $max;
|
||||
}
|
||||
}
|
||||
@@ -14,25 +14,32 @@ namespace Mailcow\Core;
|
||||
class Context
|
||||
{
|
||||
/**
|
||||
* @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins)
|
||||
* @param bool $accessAll the session may ignore object ownership (`access_all_exception`)
|
||||
* @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins)
|
||||
* @param bool $accessAll the session may ignore object ownership (`access_all_exception`)
|
||||
* @param ?string $apiAccess 'ro' / 'rw' when the request authenticated with an X-API-Key, else null
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $role,
|
||||
public readonly ?string $username,
|
||||
private readonly array $acl = [],
|
||||
public readonly bool $accessAll = false
|
||||
public readonly bool $accessAll = false,
|
||||
public readonly ?string $apiAccess = null
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromSession(array $session): self
|
||||
{
|
||||
$username = $session['mailcow_cc_username'] ?? null;
|
||||
$apiAccess = null;
|
||||
if (($session['mailcow_cc_api'] ?? false) === true) {
|
||||
$apiAccess = (($session['mailcow_cc_api_access'] ?? null) === 'rw') ? 'rw' : 'ro';
|
||||
}
|
||||
return new self(
|
||||
(string)($session['mailcow_cc_role'] ?? ''),
|
||||
is_scalar($username) ? (string)$username : null,
|
||||
is_array($session['acl'] ?? null) ? $session['acl'] : [],
|
||||
($session['access_all_exception'] ?? null) == '1'
|
||||
($session['access_all_exception'] ?? null) == '1',
|
||||
$apiAccess
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +64,12 @@ class Context
|
||||
return $this->role === 'user';
|
||||
}
|
||||
|
||||
/** Request with a read-only API key (role admin, but no write access) */
|
||||
public function isReadOnlyApi(): bool
|
||||
{
|
||||
return $this->apiAccess === 'ro';
|
||||
}
|
||||
|
||||
public function hasAcl(string $name): bool
|
||||
{
|
||||
return isset($this->acl[$name]) && $this->acl[$name] == '1';
|
||||
|
||||
@@ -29,6 +29,7 @@ Core/ shared by all modules
|
||||
```
|
||||
|
||||
Example: `Imapsync/` (sync jobs, sync sources, OAuth). Use it as the template for new modules.
|
||||
`ApiKey/` (API keys incl. the service keys of mailcow.conf) is a small module with the same layout.
|
||||
|
||||
## Layers and rules
|
||||
|
||||
|
||||
@@ -99,22 +99,6 @@ if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == "admi
|
||||
if (isset($_POST["license_validate_now"])) {
|
||||
license('verify');
|
||||
}
|
||||
if (isset($_POST["admin_api"])) {
|
||||
if (isset($_POST["admin_api"]["ro"])) {
|
||||
admin_api('ro', 'edit', $_POST);
|
||||
}
|
||||
elseif (isset($_POST["admin_api"]["rw"])) {
|
||||
admin_api('rw', 'edit', $_POST);
|
||||
}
|
||||
}
|
||||
if (isset($_POST["admin_api_regen_key"])) {
|
||||
if (isset($_POST["admin_api_regen_key"]["ro"])) {
|
||||
admin_api('ro', 'regen_key', $_POST);
|
||||
}
|
||||
elseif (isset($_POST["admin_api_regen_key"]["rw"])) {
|
||||
admin_api('rw', 'regen_key', $_POST);
|
||||
}
|
||||
}
|
||||
if (isset($_POST["rspamd_ui"])) {
|
||||
rspamd_ui('edit', $_POST);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user