Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2513116
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
101 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/.env.example b/src/.env.example
index 37557836..4e98b688 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -1,156 +1,162 @@
APP_NAME=Kolab
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://127.0.0.1:8000
#APP_PASSPHRASE=
APP_PUBLIC_URL=
APP_DOMAIN=kolabnow.com
APP_THEME=default
APP_TENANT_ID=5
APP_LOCALE=en
APP_LOCALES=en,de
APP_WITH_ADMIN=1
APP_WITH_RESELLER=1
APP_WITH_SERVICES=1
ASSET_URL=http://127.0.0.1:8000
WEBMAIL_URL=/apps
SUPPORT_URL=/support
SUPPORT_EMAIL=
LOG_CHANNEL=stack
LOG_SLOW_REQUESTS=5
DB_CONNECTION=mysql
DB_DATABASE=kolabdev
DB_HOST=127.0.0.1
DB_PASSWORD=kolab
DB_PORT=3306
DB_USERNAME=kolabdev
BROADCAST_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=file
SESSION_LIFETIME=120
OPENEXCHANGERATES_API_KEY="from openexchangerates.org"
MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube
MFA_TOTP_DIGITS=6
MFA_TOTP_INTERVAL=30
MFA_TOTP_DIGEST=sha1
IMAP_URI=ssl://127.0.0.1:993
IMAP_ADMIN_LOGIN=cyrus-admin
IMAP_ADMIN_PASSWORD=Welcome2KolabSystems
IMAP_VERIFY_HOST=false
IMAP_VERIFY_PEER=false
LDAP_BASE_DN="dc=mgmt,dc=com"
LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com"
LDAP_HOSTS=127.0.0.1
LDAP_PORT=389
LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com"
LDAP_SERVICE_BIND_PW="Welcome2KolabSystems"
LDAP_USE_SSL=false
LDAP_USE_TLS=false
# Administrative
LDAP_ADMIN_BIND_DN="cn=Directory Manager"
LDAP_ADMIN_BIND_PW="Welcome2KolabSystems"
LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com"
# Hosted (public registration)
LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com"
LDAP_HOSTED_BIND_PW="Welcome2KolabSystems"
LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com"
OPENVIDU_API_PASSWORD=MY_SECRET
OPENVIDU_API_URL=http://localhost:8080/api/
OPENVIDU_API_USERNAME=OPENVIDUAPP
OPENVIDU_API_VERIFY_TLS=true
OPENVIDU_COTURN_IP=127.0.0.1
OPENVIDU_COTURN_REDIS_DATABASE=2
OPENVIDU_COTURN_REDIS_IP=127.0.0.1
OPENVIDU_COTURN_REDIS_PASSWORD=turn
# Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL
OPENVIDU_PUBLIC_IP=127.0.0.1
OPENVIDU_PUBLIC_PORT=3478
OPENVIDU_SERVER_PORT=8080
OPENVIDU_WEBHOOK=true
OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu
# "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/
#OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged]
#OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"]
+PGP_ENABLED=
+PGP_BINARY=
+PGP_AGENT=
+PGP_GPGCONF=
+PGP_LENGTH=
+
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
SWOOLE_HOT_RELOAD_ENABLE=true
SWOOLE_HTTP_ACCESS_LOG=true
SWOOLE_HTTP_HOST=127.0.0.1
SWOOLE_HTTP_PORT=8000
SWOOLE_HTTP_REACTOR_NUM=1
SWOOLE_HTTP_WEBSOCKET=true
SWOOLE_HTTP_WORKER_NUM=1
SWOOLE_OB_OUTPUT=true
PAYMENT_PROVIDER=
MOLLIE_KEY=
STRIPE_KEY=
STRIPE_PUBLIC_KEY=
STRIPE_WEBHOOK_SECRET=
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="noreply@example.com"
MAIL_FROM_NAME="Example.com"
MAIL_REPLYTO_ADDRESS="replyto@example.com"
MAIL_REPLYTO_NAME=null
DNS_TTL=3600
DNS_SPF="v=spf1 mx -all"
DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com."
DNS_COPY_FROM=null
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_ASSET_PATH='/'
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
JWT_SECRET=
JWT_TTL=60
COMPANY_NAME=
COMPANY_ADDRESS=
COMPANY_DETAILS=
COMPANY_EMAIL=
COMPANY_LOGO=
COMPANY_FOOTER=
VAT_COUNTRIES=CH,LI
VAT_RATE=7.7
KB_ACCOUNT_DELETE=
KB_ACCOUNT_SUSPENDED=
diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php
index 13e114d3..af928e5c 100644
--- a/src/app/Auth/SecondFactor.php
+++ b/src/app/Auth/SecondFactor.php
@@ -1,322 +1,311 @@
<?php
namespace App\Auth;
use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\DB;
use Kolab2FA\Storage\Base;
/**
* A class to maintain 2-factor authentication
*/
class SecondFactor extends Base
{
protected $user;
protected $cache = [];
protected $config = [
'keymap' => [],
];
/**
* Class constructor
*
* @param \App\User $user User object
*/
public function __construct($user)
{
$this->user = $user;
parent::__construct();
}
/**
* Validate 2-factor authentication code
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse|null
*/
public function requestHandler($request)
{
// get list of configured authentication factors
$factors = $this->factors();
// do nothing if no factors configured
if (empty($factors)) {
return null;
}
if (empty($request->secondfactor) || !is_string($request->secondfactor)) {
$errors = ['secondfactor' => \trans('validation.2fareq')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// try to verify each configured factor
foreach ($factors as $factor) {
// verify the submitted code
// if (strpos($factor, 'dummy:') === 0 && (\app('env') != 'production') {
// if ($request->secondfactor === 'dummy') {
// return null;
// }
// } else
if ($this->verify($factor, $request->secondfactor)) {
return null;
}
}
$errors = ['secondfactor' => \trans('validation.2fainvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
/**
* Remove all configured 2FA methods for the current user
*
* @return bool True on success, False otherwise
*/
public function removeFactors(): bool
{
$this->cache = [];
$prefs = [];
$prefs[$this->key2property('blob')] = null;
$prefs[$this->key2property('factors')] = null;
return $this->savePrefs($prefs);
}
/**
* Returns a list of 2nd factor methods configured for the user
*/
public function factors(): array
{
// First check if the user has the 2FA SKU
if ($this->user->hasSku('2fa')) {
$factors = (array) $this->enumerate();
$factors = array_unique($factors);
return $factors;
}
return [];
}
/**
* Helper method to verify the given method/code tuple
*
* @param string $factor Factor identifier (<method>:<id>)
* @param string $code Authentication code
*
* @return bool True on successful validation
*/
protected function verify($factor, $code): bool
{
$driver = $this->getDriver($factor);
return $driver->verify($code, time());
}
/**
* Load driver class for the given authentication factor
*
* @param string $factor Factor identifier (<method>:<id>)
*
* @return \Kolab2FA\Driver\Base
*/
protected function getDriver(string $factor)
{
list($method) = explode(':', $factor, 2);
$config = \config('2fa.' . $method, []);
$driver = \Kolab2FA\Driver\Base::factory($factor, $config);
// configure driver
$driver->storage = $this;
$driver->username = $this->user->email;
return $driver;
}
/**
* Helper for seeding a Roundcube account with 2FA setup
* for testing.
*
* @param string $email Email address
*/
public static function seed(string $email): void
{
$config = [
'kolab_2fa_blob' => [
'totp:8132a46b1f741f88de25f47e' => [
'label' => 'Mobile app (TOTP)',
'created' => 1584573552,
'secret' => 'UAF477LDHZNWVLNA',
'active' => true,
],
// 'dummy:dummy' => [
// 'active' => true,
// ],
],
'kolab_2fa_factors' => [
'totp:8132a46b1f741f88de25f47e',
// 'dummy:dummy',
]
];
self::dbh()->table('users')->updateOrInsert(
['username' => $email, 'mail_host' => '127.0.0.1'],
['preferences' => serialize($config)]
);
}
/**
* Helper for generating current TOTP code for a test user
*
* @param string $email Email address
*
* @return string Generated code
*/
public static function code(string $email): string
{
$sf = new self(\App\User::where('email', $email)->first());
$driver = $sf->getDriver('totp:8132a46b1f741f88de25f47e');
return (string) $driver->get_code();
}
//******************************************************
// Methods required by Kolab2FA Storage Base
//******************************************************
/**
* Initialize the storage driver with the given config options
*/
public function init(array $config)
{
$this->config = array_merge($this->config, $config);
}
/**
* List methods activated for this user
*/
public function enumerate()
{
if ($factors = $this->getFactors()) {
return array_keys(array_filter($factors, function ($prop) {
return !empty($prop['active']);
}));
}
return [];
}
/**
* Read data for the given key
*/
public function read($key)
{
if (!isset($this->cache[$key])) {
$factors = $this->getFactors();
$this->cache[$key] = isset($factors[$key]) ? $factors[$key] : null;
}
return $this->cache[$key];
}
/**
* Save data for the given key
*/
public function write($key, $value)
{
\Log::debug(__METHOD__ . ' ' . @json_encode($value));
// TODO: Not implemented
return false;
}
/**
* Remove the data stored for the given key
*/
public function remove($key)
{
return $this->write($key, null);
}
/**
*
*/
protected function getFactors(): array
{
$prefs = $this->getPrefs();
$key = $this->key2property('blob');
return isset($prefs[$key]) ? (array) $prefs[$key] : [];
}
/**
*
*/
protected function key2property($key)
{
// map key to configured property name
if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) {
return $this->config['keymap'][$key];
}
// default
return 'kolab_2fa_' . $key;
}
/**
* Gets user preferences from Roundcube users table
*/
protected function getPrefs()
{
$user = $this->dbh()->table('users')
->select('preferences')
->where('username', strtolower($this->user->email))
->first();
return $user ? (array) unserialize($user->preferences) : null;
}
/**
* Saves user preferences in Roundcube users table.
* This will merge into old preferences
*/
protected function savePrefs($prefs)
{
$old_prefs = $this->getPrefs();
if (!is_array($old_prefs)) {
return false;
}
$prefs = array_merge($old_prefs, $prefs);
$this->dbh()->table('users')
->where('username', strtolower($this->user->email))
->update(['preferences' => serialize($prefs)]);
return true;
}
/**
* Init connection to the Roundcube database
*/
public static function dbh()
{
- $dsn = \config('2fa.dsn');
-
- if (empty($dsn)) {
- \Log::warning("2-FACTOR database not configured");
-
- return DB::connection(\config('database.default'));
- }
-
- \Config::set('database.connections.2fa', ['url' => $dsn]);
-
- return DB::connection('2fa');
+ return \App\Backends\Roundcube::dbh();
}
}
diff --git a/src/app/Backends/PGP.php b/src/app/Backends/PGP.php
new file mode 100644
index 00000000..88f0fd55
--- /dev/null
+++ b/src/app/Backends/PGP.php
@@ -0,0 +1,208 @@
+<?php
+
+namespace App\Backends;
+
+use App\User;
+use Illuminate\Support\Facades\Storage;
+
+class PGP
+{
+ /** @var \Crypt_GPG GnuPG engine instance */
+ private static $gpg;
+
+ /** @var array Crypt_GPG configuration */
+ private static $config = [];
+
+
+ /**
+ * Remove all files from the user homedir
+ *
+ * @param \App\User $user User object
+ * @param bool $del Delete also the homedir itself
+ */
+ public static function homedirCleanup(User $user, bool $del = false): void
+ {
+ $homedir = self::setHomedir($user);
+
+ // Remove all files from the filesystem (and optionally the dir itself)
+ if ($del) {
+ Storage::disk('pgp')->deleteDirectory($homedir);
+ } else {
+ Storage::disk('pgp')->delete(Storage::disk('pgp')->files($homedir));
+
+ foreach (Storage::disk('pgp')->files($homedir) as $subdir) {
+ Storage::disk('pgp')->deleteDirectory($subdir);
+ }
+ }
+
+ // Remove all files from the Enigma database
+ // Note: This will cause existing files in the Roundcube filesystem
+ // to be removed, but only if the user used the Enigma functionality
+ Roundcube::enigmaCleanup($user->email);
+ }
+
+ /**
+ * Generate a keypair.
+ * This will also initialize the user GPG homedir content.
+ *
+ * @param \App\User $user User object
+ * @param string $email Email address to use for the key
+ *
+ * @throws \Exception
+ */
+ public static function keypairCreate(User $user, string $email): void
+ {
+ self::initGPG($user, true);
+
+ if ($user->email === $email) {
+ // Make sure the homedir is empty for a new user
+ self::homedirCleanup($user);
+ }
+
+ $keygen = new \Crypt_GPG_KeyGenerator(self::$config);
+
+ $key = $keygen
+ // ->setPassphrase()
+ // ->setExpirationDate(0)
+ ->setKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length'))
+ ->setSubKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length'))
+ ->generateKey(null, $email);
+
+ // Store the keypair in Roundcube Enigma storage
+ self::dbSave(true);
+
+ // Get the ASCII armored data of the public key
+ $armor = self::$gpg->exportPublicKey((string) $key, true);
+
+ // Register the public key in DNS
+ self::keyRegister($email, $armor);
+
+ // FIXME: Should we remove the files from the worker filesystem?
+ // They are still in database and Roundcube hosts' filesystem
+ }
+
+ /**
+ * List (public and private) keys from a user keyring.
+ *
+ * @param \App\User $user User object
+ *
+ * @returns \Crypt_GPG_Key[] List of keys
+ * @throws \Exception
+ */
+ public static function listKeys(User $user): array
+ {
+ self::initGPG($user);
+
+ return self::$gpg->getKeys('');
+ }
+
+ /**
+ * Debug logging callback
+ */
+ public static function logDebug($msg): void
+ {
+ \Log::debug("[GPG] $msg");
+ }
+
+ /**
+ * Register the key in the WOAT DNS system
+ *
+ * @param string $email Email address
+ * @param string $key The ASCII-armored key content
+ */
+ public static function keyRegister(string $email, string $key)
+ {
+ // TODO
+ }
+
+ /**
+ * Remove the key from the WOAT DNS system
+ *
+ * @param string $email Email address
+ */
+ public static function keyUnregister(string $email)
+ {
+ // TODO
+ }
+
+ /**
+ * Prepare Crypt_GPG configuration
+ */
+ private static function initConfig(User $user, $nosync = false): void
+ {
+ if (!empty(self::$config) && self::$config['email'] == $user->email) {
+ return;
+ }
+
+ $debug = \config('app.debug');
+ $binary = \config('pgp.binary');
+ $agent = \config('pgp.agent');
+ $gpgconf = \config('pgp.gpgconf');
+
+ $dir = self::setHomedir($user);
+ $options = [
+ 'email' => $user->email, // this one is not a Crypt_GPG option
+ 'dir' => $dir, // this one is not a Crypt_GPG option
+ 'homedir' => \config('filesystems.disks.pgp.root') . '/' . $dir,
+ 'debug' => $debug ? 'App\Backends\PGP::logDebug' : null,
+ ];
+
+ if ($binary) {
+ $options['binary'] = $binary;
+ }
+
+ if ($agent) {
+ $options['agent'] = $agent;
+ }
+
+ if ($gpgconf) {
+ $options['gpgconf'] = $gpgconf;
+ }
+
+ self::$config = $options;
+
+ // Sync the homedir directory content with the Enigma storage
+ if (!$nosync) {
+ self::dbSync();
+ }
+ }
+
+ /**
+ * Initialize Crypt_GPG
+ */
+ private static function initGPG(User $user, $nosync = false): void
+ {
+ self::initConfig($user, $nosync);
+
+ self::$gpg = new \Crypt_GPG(self::$config);
+ }
+
+ /**
+ * Prepare a homedir for the user
+ */
+ private static function setHomedir(User $user): string
+ {
+ // Create a subfolder using two first digits of the user ID
+ $dir = sprintf('%02d', substr((string) $user->id, 0, 2)) . '/' . $user->email;
+
+ Storage::disk('pgp')->makeDirectory($dir);
+
+ return $dir;
+ }
+
+ /**
+ * Synchronize keys database of a user
+ */
+ private static function dbSync(): void
+ {
+ Roundcube::enigmaSync(self::$config['email'], self::$config['dir']);
+ }
+
+ /**
+ * Save the keys database
+ */
+ private static function dbSave($is_empty = false): void
+ {
+ Roundcube::enigmaSave(self::$config['email'], self::$config['dir'], $is_empty);
+ }
+}
diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php
new file mode 100644
index 00000000..809e79a5
--- /dev/null
+++ b/src/app/Backends/Roundcube.php
@@ -0,0 +1,256 @@
+<?php
+
+namespace App\Backends;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+
+class Roundcube
+{
+ private const FILESTORE_TABLE = 'filestore';
+ private const USERS_TABLE = 'users';
+
+ /** @var array List of GnuPG files to store */
+ private static $enigma_files = ['pubring.gpg', 'secring.gpg', 'pubring.kbx'];
+
+
+ /**
+ * Return connection to the Roundcube database
+ *
+ * @return \Illuminate\Database\ConnectionInterface
+ */
+ public static function dbh()
+ {
+ if (!\config('database.connections.roundcube')) {
+ \Log::warning("Roundcube database not configured");
+
+ return DB::connection(\config('database.default'));
+ }
+
+ return DB::connection('roundcube');
+ }
+
+ /**
+ * Remove all files from the Enigma filestore.
+ *
+ * @param string $email User email address
+ */
+ public static function enigmaCleanup(string $email): void
+ {
+ self::dbh()->table(self::FILESTORE_TABLE)
+ ->where('user_id', self::userId($email))
+ ->where('context', 'enigma')
+ ->delete();
+ }
+
+ /**
+ * List all files from the Enigma filestore.
+ *
+ * @param string $email User email address
+ *
+ * @return array List of Enigma filestore records
+ */
+ public static function enigmaList(string $email): array
+ {
+ return self::dbh()->table(self::FILESTORE_TABLE)
+ ->where('user_id', self::userId($email))
+ ->where('context', 'enigma')
+ ->orderBy('filename')
+ ->get()
+ ->all();
+ }
+
+ /**
+ * Synchronize Enigma filestore from/to specified directory
+ *
+ * @param string $email User email address
+ * @param string $homedir Directory location
+ */
+ public static function enigmaSync(string $email, string $homedir): void
+ {
+ $db = self::dbh();
+ $debug = \config('app.debug');
+ $user_id = self::userId($email);
+ $root = \config('filesystems.disks.pgp.root');
+ $fs = Storage::disk('pgp');
+ $files = [];
+
+ $result = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime')
+ ->where('user_id', $user_id)
+ ->where('context', 'enigma')
+ ->get();
+
+ foreach ($result as $record) {
+ $file = $homedir . '/' . $record->filename;
+ $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0;
+ $files[] = $record->filename;
+
+ if ($mtime < $record->mtime) {
+ $record = $db->table(self::FILESTORE_TABLE)->select('file_id', 'data', 'mtime')
+ ->where('file_id', $record->file_id)
+ ->first();
+
+ $data = $record ? base64_decode($record->data) : false;
+
+ if ($data === false) {
+ \Log::error("Failed to sync $file ({$record->file_id}). Decode error.");
+ continue;
+ }
+
+ if ($fs->put($file, $data, true)) {
+ // Note: Laravel Filesystem API does not provide touch method
+ touch("$root/$file", $record->mtime);
+
+ if ($debug) {
+ \Log::debug("[SYNC] Fetched file: $file");
+ }
+ }
+ }
+ }
+
+ // Remove files not in database
+ foreach (array_diff(self::enigmaFilesList($homedir), $files) as $file) {
+ $file = $homedir . '/' . $file;
+
+ if ($fs->delete($file)) {
+ if ($debug) {
+ \Log::debug("[SYNC] Removed file: $file");
+ }
+ }
+ }
+
+ // No records found, do initial sync if already have the keyring
+ if (empty($file)) {
+ self::enigmaSave(true, $homedir);
+ }
+ }
+
+ /**
+ * Save the keys database
+ *
+ * @param string $email User email address
+ * @param string $homedir Directory location
+ * @param bool $is_empty Set to Tre if it is a initial save
+ */
+ public static function enigmaSave(string $email, string $homedir, bool $is_empty = false): void
+ {
+ $db = self::dbh();
+ $debug = \config('app.debug');
+ $user_id = self::userId($email);
+ $fs = Storage::disk('pgp');
+ $records = [];
+
+ if (!$is_empty) {
+ $records = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime')
+ ->where('user_id', $user_id)
+ ->where('context', 'enigma')
+ ->get()
+ ->keyBy('filename')
+ ->all();
+ }
+
+ foreach (self::enigmaFilesList($homedir) as $filename) {
+ $file = $homedir . '/' . $filename;
+ $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0;
+
+ $existing = !empty($records[$filename]) ? $records[$filename] : null;
+ unset($records[$filename]);
+
+ if ($mtime && (empty($existing) || $mtime > $existing->mtime)) {
+ $data = base64_encode($fs->get($file));
+/*
+ if (empty($maxsize)) {
+ $maxsize = min($db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000;
+ }
+
+ if (strlen($data) > $maxsize) {
+ \Log::error("Failed to save $file. Size exceeds max_allowed_packet.");
+ continue;
+ }
+*/
+ $result = $db->table(self::FILESTORE_TABLE)->updateOrInsert(
+ ['user_id' => $user_id, 'context' => 'enigma', 'filename' => $filename],
+ ['mtime' => $mtime, 'data' => $data]
+ );
+
+ if ($debug) {
+ \Log::debug("[SYNC] Pushed file: $file");
+ }
+ }
+ }
+
+ // Delete removed files from database
+ foreach (array_keys($records) as $filename) {
+ $file = $homedir . '/' . $filename;
+ $result = $db->table(self::FILESTORE_TABLE)
+ ->where('user_id', $user_id)
+ ->where('context', 'enigma')
+ ->where('filename', $filename)
+ ->delete();
+
+ if ($debug) {
+ \Log::debug("[SYNC] Removed file: $file");
+ }
+ }
+ }
+
+ /**
+ * Find the Roundcube user identifier for the specified user.
+ *
+ * @param string $email User email address
+ * @param bool $create Make sure the user record exists
+ *
+ * @returns ?int Roundcube user identifier
+ */
+ public static function userId(string $email, bool $create = true): ?int
+ {
+ $db = self::dbh();
+
+ $user = $db->table(self::USERS_TABLE)->select('user_id')
+ ->where('username', \strtolower($email))
+ ->first();
+
+ // Create a user record, without it we can't use the Roundcube storage
+ if (empty($user)) {
+ if (!$create) {
+ return null;
+ }
+
+ $uri = \parse_url(\config('imap.uri'));
+
+ return (int) $db->table(self::USERS_TABLE)->insertGetId(
+ [
+ 'username' => $email,
+ 'mail_host' => $uri['host'],
+ 'created' => now()->toDateTimeString(),
+ ],
+ 'user_id'
+ );
+ }
+
+ return (int) $user->user_id;
+ }
+
+ /**
+ * Returns list of Enigma user homedir files to backup/sync
+ */
+ private static function enigmaFilesList(string $homedir)
+ {
+ $files = [];
+ $fs = Storage::disk('pgp');
+
+ foreach (self::$enigma_files as $file) {
+ if ($fs->exists($homedir . '/' . $file)) {
+ $files[] = $file;
+ }
+ }
+
+ foreach ($fs->files($homedir . '/private-keys-v1.d') as $file) {
+ if (preg_match('/\.key$/', $file)) {
+ $files[] = substr($file, strlen($homedir . '/'));
+ }
+ }
+
+ return $files;
+ }
+}
diff --git a/src/app/Jobs/PGP/KeyCreateJob.php b/src/app/Jobs/PGP/KeyCreateJob.php
new file mode 100644
index 00000000..15393702
--- /dev/null
+++ b/src/app/Jobs/PGP/KeyCreateJob.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Jobs\PGP;
+
+use App\Jobs\UserJob;
+
+/**
+ * Create a GPG keypair for a user (or alias).
+ *
+ * Throws exceptions for the following reasons:
+ *
+ * * The user is marked as deleted (`$user->isDeleted()`), or
+ * * the user is actually deleted (`$user->deleted_at`)
+ * * the alias is actually deleted
+ * * there was an error in keypair generation process
+ */
+class KeyCreateJob extends UserJob
+{
+ /**
+ * Create a new job instance.
+ *
+ * @param int $userId User identifier.
+ * @param string $userEmail User email address for the key
+ *
+ * @return void
+ */
+ public function __construct(int $userId, string $userEmail)
+ {
+ $this->userId = $userId;
+ $this->userEmail = $userEmail;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ *
+ * @throws \Exception
+ */
+ public function handle()
+ {
+ $user = $this->getUser();
+
+ if (!$user) {
+ return;
+ }
+
+ // sanity checks
+ if ($user->isDeleted()) {
+ $this->fail(new \Exception("User {$this->userId} is marked as deleted."));
+ return;
+ }
+
+ if ($user->trashed()) {
+ $this->fail(new \Exception("User {$this->userId} is actually deleted."));
+ return;
+ }
+
+ if (
+ $this->userEmail != $user->email
+ && !$user->aliases()->where('alias', $this->userEmail)->exists()
+ ) {
+ $this->fail(new \Exception("Alias {$this->userEmail} is actually deleted."));
+ return;
+ }
+
+ \App\Backends\PGP::keypairCreate($user, $this->userEmail);
+ }
+}
diff --git a/src/app/Jobs/PGP/KeyUnregisterJob.php b/src/app/Jobs/PGP/KeyUnregisterJob.php
new file mode 100644
index 00000000..4c17f477
--- /dev/null
+++ b/src/app/Jobs/PGP/KeyUnregisterJob.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Jobs\PGP;
+
+use App\Jobs\CommonJob;
+
+/**
+ * Remove the GPG key from the WOAT DNS system.
+ */
+class KeyUnregisterJob extends CommonJob
+{
+ /**
+ * The email property.
+ *
+ * @var string
+ */
+ protected $email;
+
+ /**
+ * Create a new job instance.
+ *
+ * @param string $email User email address for the key
+ *
+ * @return void
+ */
+ public function __construct(string $email)
+ {
+ $this->email = $email;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ *
+ * @throws \Exception
+ */
+ public function handle()
+ {
+ \App\Backends\PGP::keyUnregister($this->email);
+ }
+}
diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php
index 8f9891a5..791e06e6 100644
--- a/src/app/Observers/UserAliasObserver.php
+++ b/src/app/Observers/UserAliasObserver.php
@@ -1,84 +1,93 @@
<?php
namespace App\Observers;
use App\Domain;
+use App\Tenant;
use App\User;
use App\UserAlias;
class UserAliasObserver
{
/**
* Handle the "creating" event on an alias
*
* Ensures that there's no user with specified email.
*
* @param \App\UserAlias $alias The user email alias
*
* @return bool
*/
public function creating(UserAlias $alias): bool
{
$alias->alias = \strtolower($alias->alias);
list($login, $domain) = explode('@', $alias->alias);
$domain = Domain::where('namespace', $domain)->first();
if (!$domain) {
\Log::error("Failed creating alias {$alias->alias}. Domain does not exist.");
return false;
}
if ($alias->user) {
if ($alias->user->tenant_id != $domain->tenant_id) {
\Log::error("Reseller for user '{$alias->user->email}' and domain '{$domain->namespace}' differ.");
return false;
}
}
return true;
}
/**
* Handle the user alias "created" event.
*
* @param \App\UserAlias $alias User email alias
*
* @return void
*/
public function created(UserAlias $alias)
{
if ($alias->user) {
\App\Jobs\User\UpdateJob::dispatch($alias->user_id);
+
+ if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) {
+ \App\Jobs\PGP\KeyCreateJob::dispatch($alias->user_id, $alias->alias);
+ }
}
}
/**
* Handle the user setting "updated" event.
*
* @param \App\UserAlias $alias User email alias
*
* @return void
*/
public function updated(UserAlias $alias)
{
if ($alias->user) {
\App\Jobs\User\UpdateJob::dispatch($alias->user_id);
}
}
/**
* Handle the user setting "deleted" event.
*
* @param \App\UserAlias $alias User email alias
*
* @return void
*/
public function deleted(UserAlias $alias)
{
if ($alias->user) {
\App\Jobs\User\UpdateJob::dispatch($alias->user_id);
+
+ if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) {
+ \App\Jobs\PGP\KeyUnregisterJob::dispatch($alias->alias);
+ }
}
}
}
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
index 7e3c86f9..1bc37b4e 100644
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -1,374 +1,378 @@
<?php
namespace App\Observers;
use App\Entitlement;
use App\Domain;
use App\Group;
use App\Transaction;
use App\User;
use App\Wallet;
use Illuminate\Support\Facades\DB;
class UserObserver
{
/**
* Handle the "creating" event.
*
* Ensure that the user is created with a random, large integer.
*
* @param \App\User $user The user being created.
*
* @return void
*/
public function creating(User $user)
{
if (!$user->id) {
while (true) {
$allegedly_unique = \App\Utils::uuidInt();
if (!User::withTrashed()->find($allegedly_unique)) {
$user->{$user->getKeyName()} = $allegedly_unique;
break;
}
}
}
$user->email = \strtolower($user->email);
// only users that are not imported get the benefit of the doubt.
$user->status |= User::STATUS_NEW | User::STATUS_ACTIVE;
$user->tenant_id = \config('app.tenant_id');
}
/**
* Handle the "created" event.
*
* Ensures the user has at least one wallet.
*
* Should ensure some basic settings are available as well.
*
* @param \App\User $user The user created.
*
* @return void
*/
public function created(User $user)
{
$settings = [
'country' => \App\Utils::countryForRequest(),
'currency' => 'CHF',
/*
'first_name' => '',
'last_name' => '',
'billing_address' => '',
'organization' => '',
'phone' => '',
'external_email' => '',
*/
];
foreach ($settings as $key => $value) {
$settings[$key] = [
'key' => $key,
'value' => $value,
'user_id' => $user->id,
];
}
// Note: Don't use setSettings() here to bypass UserSetting observers
// Note: This is a single multi-insert query
$user->settings()->insert(array_values($settings));
$user->wallets()->create();
// Create user record in LDAP, then check if the account is created in IMAP
$chain = [
new \App\Jobs\User\VerifyJob($user->id),
];
\App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id);
+
+ if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) {
+ \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email);
+ }
}
/**
* Handle the "deleted" event.
*
* @param \App\User $user The user deleted.
*
* @return void
*/
public function deleted(User $user)
{
// Remove the user from existing groups
$wallet = $user->wallet();
if ($wallet && $wallet->owner) {
$wallet->owner->groups()->each(function ($group) use ($user) {
if (in_array($user->email, $group->members)) {
$group->members = array_diff($group->members, [$user->email]);
$group->save();
}
});
}
// Debit the reseller's wallet with the user negative balance
$balance = 0;
foreach ($user->wallets as $wallet) {
// Note: here we assume all user wallets are using the same currency.
// It might get changed in the future
$balance += $wallet->balance;
}
if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) {
$wallet->debit($balance * -1, "Deleted user {$user->email}");
}
}
/**
* Handle the "deleting" event.
*
* @param User $user The user that is being deleted.
*
* @return void
*/
public function deleting(User $user)
{
if ($user->isForceDeleting()) {
$this->forceDeleting($user);
return;
}
// TODO: Especially in tests we're doing delete() on a already deleted user.
// Should we escape here - for performance reasons?
// TODO: I think all of this should use database transactions
// Entitlements do not have referential integrity on the entitled object, so this is our
// way of doing an onDelete('cascade') without the foreign key.
$entitlements = Entitlement::where('entitleable_id', $user->id)
->where('entitleable_type', User::class)->get();
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
// Remove owned users/domains
$wallets = $user->wallets()->pluck('id')->all();
$assignments = Entitlement::whereIn('wallet_id', $wallets)->get();
$users = [];
$domains = [];
$groups = [];
$entitlements = [];
foreach ($assignments as $entitlement) {
if ($entitlement->entitleable_type == Domain::class) {
$domains[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) {
$users[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Group::class) {
$groups[] = $entitlement->entitleable_id;
} else {
$entitlements[] = $entitlement;
}
}
// Domains/users/entitlements need to be deleted one by one to make sure
// events are fired and observers can do the proper cleanup.
if (!empty($users)) {
foreach (User::whereIn('id', array_unique($users))->get() as $_user) {
$_user->delete();
}
}
if (!empty($domains)) {
foreach (Domain::whereIn('id', array_unique($domains))->get() as $_domain) {
$_domain->delete();
}
}
if (!empty($groups)) {
foreach (Group::whereIn('id', array_unique($groups))->get() as $_group) {
$_group->delete();
}
}
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
// FIXME: What do we do with user wallets?
\App\Jobs\User\DeleteJob::dispatch($user->id);
}
/**
* Handle the "deleting" event on forceDelete() call.
*
* @param User $user The user that is being deleted.
*
* @return void
*/
public function forceDeleting(User $user)
{
// TODO: We assume that at this moment all belongings are already soft-deleted.
// Remove owned users/domains
$wallets = $user->wallets()->pluck('id')->all();
$assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get();
$entitlements = [];
$domains = [];
$groups = [];
$users = [];
foreach ($assignments as $entitlement) {
$entitlements[] = $entitlement->id;
if ($entitlement->entitleable_type == Domain::class) {
$domains[] = $entitlement->entitleable_id;
} elseif (
$entitlement->entitleable_type == User::class
&& $entitlement->entitleable_id != $user->id
) {
$users[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Group::class) {
$groups[] = $entitlement->entitleable_id;
}
}
// Remove the user "direct" entitlements explicitely, if they belong to another
// user's wallet they will not be removed by the wallets foreign key cascade
Entitlement::withTrashed()
->where('entitleable_id', $user->id)
->where('entitleable_type', User::class)
->forceDelete();
// Users need to be deleted one by one to make sure observers can do the proper cleanup.
if (!empty($users)) {
foreach (User::withTrashed()->whereIn('id', array_unique($users))->get() as $_user) {
$_user->forceDelete();
}
}
// Domains can be just removed
if (!empty($domains)) {
Domain::withTrashed()->whereIn('id', array_unique($domains))->forceDelete();
}
// Groups can be just removed
if (!empty($groups)) {
Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete();
}
// Remove transactions, they also have no foreign key constraint
Transaction::where('object_type', Entitlement::class)
->whereIn('object_id', $entitlements)
->delete();
Transaction::where('object_type', Wallet::class)
->whereIn('object_id', $wallets)
->delete();
}
/**
* Handle the user "restoring" event.
*
* @param \App\User $user The user
*
* @return void
*/
public function restoring(User $user)
{
// Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore
if ($user->isDeleted()) {
$user->status ^= User::STATUS_DELETED;
}
if ($user->isLdapReady()) {
$user->status ^= User::STATUS_LDAP_READY;
}
if ($user->isImapReady()) {
$user->status ^= User::STATUS_IMAP_READY;
}
if ($user->isSuspended()) {
$user->status ^= User::STATUS_SUSPENDED;
}
$user->status |= User::STATUS_ACTIVE;
// Note: $user->save() is invoked between 'restoring' and 'restored' events
}
/**
* Handle the user "restored" event.
*
* @param \App\User $user The user
*
* @return void
*/
public function restored(User $user)
{
$wallets = $user->wallets()->pluck('id')->all();
// Restore user entitlements
// We'll restore only these that were deleted last. So, first we get
// the maximum deleted_at timestamp and then use it to select
// entitlements for restore
$deleted_at = \App\Entitlement::withTrashed()
->where('entitleable_id', $user->id)
->where('entitleable_type', User::class)
->max('deleted_at');
if ($deleted_at) {
$threshold = (new \Carbon\Carbon($deleted_at))->subMinute();
// We need at least the user domain so it can be created in ldap.
// FIXME: What if the domain is owned by someone else?
$domain = $user->domain();
if ($domain->trashed() && !$domain->isPublic()) {
// Note: Domain entitlements will be restored by the DomainObserver
$domain->restore();
}
// Restore user entitlements
\App\Entitlement::withTrashed()
->where('entitleable_id', $user->id)
->where('entitleable_type', User::class)
->where('deleted_at', '>=', $threshold)
->update(['updated_at' => now(), 'deleted_at' => null]);
// Note: We're assuming that cost of entitlements was correct
// on user deletion, so we don't have to re-calculate it again.
}
// FIXME: Should we reset user aliases? or re-validate them in any way?
// Create user record in LDAP, then run the verification process
$chain = [
new \App\Jobs\User\VerifyJob($user->id),
];
\App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id);
}
/**
* Handle the "retrieving" event.
*
* @param User $user The user that is being retrieved.
*
* @todo This is useful for audit.
*
* @return void
*/
public function retrieving(User $user)
{
// TODO \App\Jobs\User\ReadJob::dispatch($user->id);
}
/**
* Handle the "updating" event.
*
* @param User $user The user that is being updated.
*
* @return void
*/
public function updating(User $user)
{
\App\Jobs\User\UpdateJob::dispatch($user->id);
}
}
diff --git a/src/app/Tenant.php b/src/app/Tenant.php
index 76a46e68..a4b572bf 100644
--- a/src/app/Tenant.php
+++ b/src/app/Tenant.php
@@ -1,104 +1,105 @@
<?php
namespace App;
use App\Traits\SettingsTrait;
use Illuminate\Database\Eloquent\Model;
/**
* The eloquent definition of a Tenant.
*
* @property int $id
* @property string $title
*/
class Tenant extends Model
{
use SettingsTrait;
protected $fillable = [
'id',
'title',
];
protected $keyType = 'bigint';
/**
* Utility method to get tenant-specific system setting.
* If the setting is not specified for the tenant a system-wide value will be returned.
*
* @param int $tenantId Tenant identifier
* @param string $key Setting name
*
* @return mixed Setting value
*/
public static function getConfig($tenantId, string $key)
{
// Cache the tenant instance in memory
static $tenant;
if (empty($tenant) || $tenant->id != $tenantId) {
$tenant = null;
if ($tenantId) {
$tenant = self::findOrFail($tenantId);
}
}
// Supported options (TODO: document this somewhere):
// - app.name (tenants.title will be returned)
// - app.public_url and app.url
// - app.support_url
// - mail.from.address and mail.from.name
// - mail.reply_to.address and mail.reply_to.name
// - app.kb.account_delete and app.kb.account_suspended
+ // - pgp.enable
if ($key == 'app.name') {
return $tenant ? $tenant->title : \config($key);
}
$value = $tenant ? $tenant->getSetting($key) : null;
return $value !== null ? $value : \config($key);
}
/**
* Discounts assigned to this tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function discounts()
{
return $this->hasMany('App\Discount');
}
/**
* Any (additional) settings of this tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function settings()
{
return $this->hasMany('App\TenantSetting');
}
/**
* SignupInvitations assigned to this tenant.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function signupInvitations()
{
return $this->hasMany('App\SignupInvitation');
}
/*
* Returns the wallet of the tanant (reseller's wallet).
*
* @return ?\App\Wallet A wallet object
*/
public function wallet(): ?Wallet
{
$user = \App\User::where('role', 'reseller')->where('tenant_id', $this->id)->first();
return $user ? $user->wallets->first() : null;
}
}
diff --git a/src/composer.json b/src/composer.json
index eb4764cd..da982cf5 100644
--- a/src/composer.json
+++ b/src/composer.json
@@ -1,84 +1,85 @@
{
"name": "laravel/laravel",
"type": "project",
"description": "The Laravel Framework.",
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git"
}
],
"require": {
"php": "^7.3",
"barryvdh/laravel-dompdf": "^0.8.6",
"doctrine/dbal": "^2.13",
"dyrynda/laravel-nullable-fields": "*",
"fideloper/proxy": "^4.0",
"guzzlehttp/guzzle": "^7.3",
"kolab/net_ldap3": "dev-master",
"laravel/framework": "6.*",
"laravel/horizon": "^3",
"laravel/tinker": "^2.4",
"mlocati/spf-lib": "^3.0",
"mollie/laravel-mollie": "^2.9",
"morrislaptop/laravel-queue-clear": "^1.2",
+ "pear/crypt_gpg": "dev-master",
"silviolleite/laravelpwa": "^2.0",
"spatie/laravel-translatable": "^4.2",
"spomky-labs/otphp": "~4.0.0",
"stripe/stripe-php": "^7.29",
"swooletw/laravel-swoole": "^2.6",
"tymon/jwt-auth": "^1.0"
},
"require-dev": {
"beyondcode/laravel-er-diagram-generator": "^1.3",
"code-lts/doctum": "^5.1",
"kirschbaum-development/mail-intercept": "^0.2.4",
"laravel/dusk": "~6.15.0",
"nunomaduro/larastan": "^0.7",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^9"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"classmap": [
"database/seeds",
"include"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
}
}
diff --git a/src/config/2fa.php b/src/config/2fa.php
index 85017364..df8a8765 100644
--- a/src/config/2fa.php
+++ b/src/config/2fa.php
@@ -1,14 +1,12 @@
<?php
return [
'totp' => [
'digits' => (int) env('MFA_TOTP_DIGITS', 6),
'interval' => (int) env('MFA_TOTP_INTERVAL', 30),
'digest' => env('MFA_TOTP_DIGEST', 'sha1'),
'issuer' => env('APP_NAME', 'Laravel'),
],
- 'dsn' => env('MFA_DSN'),
-
];
diff --git a/src/config/database.php b/src/config/database.php
index 1e5c49f0..b0b4cc2d 100644
--- a/src/config/database.php
+++ b/src/config/database.php
@@ -1,152 +1,156 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for all database work. Of course
| you may use many connections at once using the Database library.
|
*/
'default' => env('DB_CONNECTION', 'mysql'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Here are each of the database connections setup for your application.
| Of course, examples of configuring each database platform that is
| supported by Laravel is shown below to make development simple.
|
|
| All database work in Laravel is done through the PHP PDO facilities
| so make sure you have the driver for your particular database of
| choice installed on your machine before you begin development.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DATABASE_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
],
'2fa' => [
'driver' => 'mysql',
'url' => env('MFA_DSN')
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'timezone' => '+00:00',
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'schema' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
],
+
+ 'roundcube' => [
+ 'url' => env('DB_ROUNDCUBE_URL', env('MFA_DSN')),
+ ],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run in the database.
|
*/
'migrations' => 'migrations',
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as APC or Memcached. Laravel makes it easy to dig right in.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'predis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'predis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_CACHE_DB', 1),
],
],
];
diff --git a/src/config/filesystems.php b/src/config/filesystems.php
index 77fa5ded..cdab2570 100644
--- a/src/config/filesystems.php
+++ b/src/config/filesystems.php
@@ -1,69 +1,74 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application. Just store away!
|
*/
'default' => env('FILESYSTEM_DRIVER', 'local'),
/*
|--------------------------------------------------------------------------
| Default Cloud Filesystem Disk
|--------------------------------------------------------------------------
|
| Many applications store files both locally and in the cloud. For this
| reason, you may specify a default "cloud" driver here. This driver
| will be bound as the Cloud disk implementation in the container.
|
*/
'cloud' => env('FILESYSTEM_CLOUD', 's3'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Here you may configure as many filesystem "disks" as you wish, and you
| may even configure multiple disks of the same driver. Defaults have
| been setup for each driver as an example of the required options.
|
| Supported Drivers: "local", "ftp", "sftp", "s3", "rackspace"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
],
+ 'pgp' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/keys'),
+ ],
+
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
],
],
];
diff --git a/src/config/pgp.php b/src/config/pgp.php
new file mode 100644
index 00000000..55571107
--- /dev/null
+++ b/src/config/pgp.php
@@ -0,0 +1,20 @@
+<?php
+
+return [
+
+ // Enables PGP keypair generation on user creation
+ 'enable' => env('PGP_ENABLE', false),
+
+ // gpg binary location
+ 'binary' => env('PGP_BINARY'),
+
+ // gpg-agent location
+ 'agent' => env('PGP_AGENT'),
+
+ // gpgconf location
+ 'gpgconf' => env('PGP_GPGCONF'),
+
+ // Default size of the new RSA key
+ 'length' => (int) env('PGP_LENGTH', 3072),
+
+];
diff --git a/src/phpunit.xml b/src/phpunit.xml
index 29013e4e..cbb78f98 100644
--- a/src/phpunit.xml
+++ b/src/phpunit.xml
@@ -1,45 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="Functional">
<directory suffix="Test.php">tests/Functional</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">tests/Feature</directory>
</testsuite>
<testsuite name="Browser">
<directory suffix="Test.php">tests/Browser</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<logging>
<testdoxHtml outputFile="./tests/report/testdox.html" />
</logging>
<php>
<server name="APP_ENV" value="testing"/>
<server name="APP_DEBUG" value="true"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="MAIL_DRIVER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="SWOOLE_HTTP_ACCESS_LOG" value="false"/>
+ <server name="PGP_LENGTH" value="1024"/>
</php>
</phpunit>
diff --git a/src/tests/Feature/Jobs/PGP/KeyCreateTest.php b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php
new file mode 100644
index 00000000..2f3e6c86
--- /dev/null
+++ b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace Tests\Feature\Jobs\PGP;
+
+use App\Backends\PGP;
+use App\Backends\Roundcube;
+use App\User;
+use App\UserAlias;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class KeyCreateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $user = $this->getTestUser('john@kolab.org');
+ UserAlias::where('alias', 'test-alias@kolab.org')->delete();
+ PGP::homedirCleanup($user);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ UserAlias::where('alias', 'test-alias@kolab.org')->delete();
+ PGP::homedirCleanup($user);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group pgp
+ */
+ public function testHandle(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+
+ $job = new \App\Jobs\PGP\KeyCreateJob($user->id, $user->email);
+ $job->handle();
+
+ // Assert the Enigma storage has been initialized and contains the key
+ $files = Roundcube::enigmaList($user->email);
+ // TODO: More detailed asserts on the filestore content, but it's specific to GPG version
+ $this->assertTrue(count($files) > 1);
+
+ // Assert the created keypair parameters
+ $keys = PGP::listKeys($user);
+
+ $this->assertCount(1, $keys);
+
+ $userIds = $keys[0]->getUserIds();
+ $this->assertCount(1, $userIds);
+ $this->assertSame($user->email, $userIds[0]->getEmail());
+ $this->assertSame('', $userIds[0]->getName());
+ $this->assertSame('', $userIds[0]->getComment());
+ $this->assertSame(true, $userIds[0]->isValid());
+ $this->assertSame(false, $userIds[0]->isRevoked());
+
+ $key = $keys[0]->getPrimaryKey();
+ $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm());
+ $this->assertSame(0, $key->getExpirationDate());
+ $this->assertSame((int) \config('pgp.length'), $key->getLength());
+ $this->assertSame(true, $key->hasPrivate());
+ $this->assertSame(true, $key->canSign());
+ $this->assertSame(false, $key->canEncrypt());
+ $this->assertSame(false, $key->isRevoked());
+
+ $key = $keys[0]->getSubKeys()[1];
+ $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm());
+ $this->assertSame(0, $key->getExpirationDate());
+ $this->assertSame((int) \config('pgp.length'), $key->getLength());
+ $this->assertSame(false, $key->canSign());
+ $this->assertSame(true, $key->canEncrypt());
+ $this->assertSame(false, $key->isRevoked());
+
+ // TODO: Assert the public key in DNS?
+
+ // Test an alias
+ Queue::fake();
+ UserAlias::create(['user_id' => $user->id, 'alias' => 'test-alias@kolab.org']);
+ $job = new \App\Jobs\PGP\KeyCreateJob($user->id, 'test-alias@kolab.org');
+ $job->handle();
+
+ // Assert the created keypair parameters
+ $keys = PGP::listKeys($user);
+
+ $this->assertCount(2, $keys);
+
+ $userIds = $keys[1]->getUserIds();
+ $this->assertCount(1, $userIds);
+ $this->assertSame('test-alias@kolab.org', $userIds[0]->getEmail());
+ $this->assertSame('', $userIds[0]->getName());
+ $this->assertSame('', $userIds[0]->getComment());
+ $this->assertSame(true, $userIds[0]->isValid());
+ $this->assertSame(false, $userIds[0]->isRevoked());
+
+ $key = $keys[1]->getPrimaryKey();
+ $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm());
+ $this->assertSame(0, $key->getExpirationDate());
+ $this->assertSame((int) \config('pgp.length'), $key->getLength());
+ $this->assertSame(true, $key->hasPrivate());
+ $this->assertSame(true, $key->canSign());
+ $this->assertSame(false, $key->canEncrypt());
+ $this->assertSame(false, $key->isRevoked());
+
+ $key = $keys[1]->getSubKeys()[1];
+ $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm());
+ $this->assertSame(0, $key->getExpirationDate());
+ $this->assertSame((int) \config('pgp.length'), $key->getLength());
+ $this->assertSame(false, $key->canSign());
+ $this->assertSame(true, $key->canEncrypt());
+ $this->assertSame(false, $key->isRevoked());
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
index a497bcfd..379d39e9 100644
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -1,909 +1,949 @@
<?php
namespace Tests\Feature;
use App\Domain;
use App\Group;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UserTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
}
public function tearDown(): void
{
+ \App\TenantSetting::truncate();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
parent::tearDown();
}
/**
* Tests for User::assignPackage()
*/
public function testAssignPackage(): void
{
$this->markTestIncomplete();
}
/**
* Tests for User::assignPlan()
*/
public function testAssignPlan(): void
{
$this->markTestIncomplete();
}
/**
* Tests for User::assignSku()
*/
public function testAssignSku(): void
{
$this->markTestIncomplete();
}
/**
* Verify a wallet assigned a controller is among the accounts of the assignee.
*/
public function testAccounts(): void
{
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$this->assertTrue($userA->wallets()->count() == 1);
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id);
}
public function testCanDelete(): void
{
$this->markTestIncomplete();
}
/**
* Test User::canRead() method
*/
public function testCanRead(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canRead($admin));
$this->assertTrue($admin->canRead($john));
$this->assertTrue($admin->canRead($jack));
$this->assertTrue($admin->canRead($reseller1));
$this->assertTrue($admin->canRead($reseller2));
$this->assertTrue($admin->canRead($domain));
$this->assertTrue($admin->canRead($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canRead($john));
$this->assertTrue($reseller1->canRead($jack));
$this->assertTrue($reseller1->canRead($reseller1));
$this->assertTrue($reseller1->canRead($domain));
$this->assertTrue($reseller1->canRead($domain->wallet()));
$this->assertFalse($reseller1->canRead($reseller2));
$this->assertFalse($reseller1->canRead($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canRead($reseller2));
$this->assertFalse($reseller2->canRead($john));
$this->assertFalse($reseller2->canRead($jack));
$this->assertFalse($reseller2->canRead($reseller1));
$this->assertFalse($reseller2->canRead($domain));
$this->assertFalse($reseller2->canRead($domain->wallet()));
$this->assertFalse($reseller2->canRead($admin));
// Normal user - account owner
$this->assertTrue($john->canRead($john));
$this->assertTrue($john->canRead($ned));
$this->assertTrue($john->canRead($jack));
$this->assertTrue($john->canRead($domain));
$this->assertTrue($john->canRead($domain->wallet()));
$this->assertFalse($john->canRead($reseller1));
$this->assertFalse($john->canRead($reseller2));
$this->assertFalse($john->canRead($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canRead($jack));
$this->assertFalse($jack->canRead($john));
$this->assertFalse($jack->canRead($domain));
$this->assertFalse($jack->canRead($domain->wallet()));
$this->assertFalse($jack->canRead($reseller1));
$this->assertFalse($jack->canRead($reseller2));
$this->assertFalse($jack->canRead($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canRead($ned));
$this->assertTrue($ned->canRead($john));
$this->assertTrue($ned->canRead($jack));
$this->assertTrue($ned->canRead($domain));
$this->assertTrue($ned->canRead($domain->wallet()));
$this->assertFalse($ned->canRead($reseller1));
$this->assertFalse($ned->canRead($reseller2));
$this->assertFalse($ned->canRead($admin));
}
/**
* Test User::canUpdate() method
*/
public function testCanUpdate(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canUpdate($admin));
$this->assertTrue($admin->canUpdate($john));
$this->assertTrue($admin->canUpdate($jack));
$this->assertTrue($admin->canUpdate($reseller1));
$this->assertTrue($admin->canUpdate($reseller2));
$this->assertTrue($admin->canUpdate($domain));
$this->assertTrue($admin->canUpdate($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canUpdate($john));
$this->assertTrue($reseller1->canUpdate($jack));
$this->assertTrue($reseller1->canUpdate($reseller1));
$this->assertTrue($reseller1->canUpdate($domain));
$this->assertTrue($reseller1->canUpdate($domain->wallet()));
$this->assertFalse($reseller1->canUpdate($reseller2));
$this->assertFalse($reseller1->canUpdate($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canUpdate($reseller2));
$this->assertFalse($reseller2->canUpdate($john));
$this->assertFalse($reseller2->canUpdate($jack));
$this->assertFalse($reseller2->canUpdate($reseller1));
$this->assertFalse($reseller2->canUpdate($domain));
$this->assertFalse($reseller2->canUpdate($domain->wallet()));
$this->assertFalse($reseller2->canUpdate($admin));
// Normal user - account owner
$this->assertTrue($john->canUpdate($john));
$this->assertTrue($john->canUpdate($ned));
$this->assertTrue($john->canUpdate($jack));
$this->assertTrue($john->canUpdate($domain));
$this->assertFalse($john->canUpdate($domain->wallet()));
$this->assertFalse($john->canUpdate($reseller1));
$this->assertFalse($john->canUpdate($reseller2));
$this->assertFalse($john->canUpdate($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canUpdate($jack));
$this->assertFalse($jack->canUpdate($john));
$this->assertFalse($jack->canUpdate($domain));
$this->assertFalse($jack->canUpdate($domain->wallet()));
$this->assertFalse($jack->canUpdate($reseller1));
$this->assertFalse($jack->canUpdate($reseller2));
$this->assertFalse($jack->canUpdate($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canUpdate($ned));
$this->assertTrue($ned->canUpdate($john));
$this->assertTrue($ned->canUpdate($jack));
$this->assertTrue($ned->canUpdate($domain));
$this->assertFalse($ned->canUpdate($domain->wallet()));
$this->assertFalse($ned->canUpdate($reseller1));
$this->assertFalse($ned->canUpdate($reseller2));
$this->assertFalse($ned->canUpdate($admin));
}
/**
* Test user create/creating observer
*/
public function testCreate(): void
{
Queue::fake();
$domain = \config('app.domain');
$user = User::create(['email' => 'USER-test@' . \strtoupper($domain)]);
$result = User::where('email', 'user-test@' . $domain)->first();
$this->assertSame('user-test@' . $domain, $result->email);
$this->assertSame($user->id, $result->id);
$this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status);
}
/**
* Verify user creation process
*/
public function testCreateJobs(): void
{
- // Fake the queue, assert that no jobs were pushed...
Queue::fake();
- Queue::assertNothingPushed();
$user = User::create([
'email' => 'user-test@' . \config('app.domain')
]);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
+ Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
Queue::assertPushedWithChain(
\App\Jobs\User\CreateJob::class,
[
\App\Jobs\User\VerifyJob::class,
]
);
/*
FIXME: Looks like we can't really do detailed assertions on chained jobs
Another thing to consider is if we maybe should run these jobs
independently (not chained) and make sure there's no race-condition
in status update
Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1);
Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
});
*/
}
+ /**
+ * Verify user creation process invokes the PGP keys creation job (if configured)
+ */
+ public function testCreatePGPJob(): void
+ {
+ Queue::fake();
+
+ \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1);
+
+ $user = User::create([
+ 'email' => 'user-test@' . \config('app.domain')
+ ]);
+
+ Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
+
+ Queue::assertPushed(
+ \App\Jobs\PGP\KeyCreateJob::class,
+ function ($job) use ($user) {
+ $userEmail = TestCase::getObjectProperty($job, 'userEmail');
+ $userId = TestCase::getObjectProperty($job, 'userId');
+
+ return $userEmail === $user->email
+ && $userId === $user->id;
+ }
+ );
+ }
+
/**
* Tests for User::domains()
*/
public function testDomains(): void
{
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('useraccount.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE,
'type' => Domain::TYPE_PUBLIC,
]);
$domains = collect($user->domains())->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertContains('kolab.org', $domains);
// Jack is not the wallet controller, so for him the list should not
// include John's domains, kolab.org specifically
$user = $this->getTestUser('jack@kolab.org');
$domains = collect($user->domains())->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertNotContains('kolab.org', $domains);
// Public domains of other tenants should not be returned
$tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first();
$domain->tenant_id = $tenant->id;
$domain->save();
$domains = collect($user->domains())->pluck('namespace')->all();
$this->assertNotContains($domain->namespace, $domains);
}
/**
* Test User::hasSku() method
*/
public function testHasSku(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->assertTrue($john->hasSku('mailbox'));
$this->assertTrue($john->hasSku('storage'));
$this->assertFalse($john->hasSku('beta'));
$this->assertFalse($john->hasSku('unknown'));
}
public function testUserQuota(): void
{
// TODO: This test does not test much, probably could be removed
// or moved to somewhere else, or extended with
// other entitlements() related cases.
$user = $this->getTestUser('john@kolab.org');
$storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$count = 0;
foreach ($user->entitlements()->get() as $entitlement) {
if ($entitlement->sku_id == $storage_sku->id) {
$count += 1;
}
}
$this->assertTrue($count == 5);
}
/**
* Test user deletion
*/
public function testDelete(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package);
$id = $user->id;
$this->assertCount(7, $user->entitlements()->get());
$user->delete();
$this->assertCount(0, $user->entitlements()->get());
$this->assertTrue($user->fresh()->trashed());
$this->assertFalse($user->fresh()->isDeleted());
// Delete the user for real
$job = new \App\Jobs\User\DeleteJob($id);
$job->handle();
$this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted());
$user->forceDelete();
$this->assertCount(0, User::withTrashed()->where('id', $id)->get());
// Test an account with users, domain, and group
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userC = $this->getTestUser('UserAccountC@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$userA->assignPackage($package_kolab, $userC);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->assignToWallet($userA->wallets->first());
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id);
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsC->count());
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
// Delete non-controller user
$userC->delete();
$this->assertTrue($userC->fresh()->trashed());
$this->assertFalse($userC->fresh()->isDeleted());
$this->assertSame(0, $entitlementsC->count());
// Delete the controller (and expect "sub"-users to be deleted too)
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertSame(0, $entitlementsGroup->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domain->fresh()->trashed());
$this->assertTrue($group->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($group->isDeleted());
$userA->forceDelete();
$all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id);
$this->assertSame(0, $all_entitlements->withTrashed()->count());
$this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get());
$this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
}
/**
* Test user deletion vs. group membership
*/
public function testDeleteAndGroups(): void
{
Queue::fake();
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userA->assignPackage($package_kolab, $userB);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->members = ['test@gmail.com', $userB->email];
$group->assignToWallet($userA->wallets->first());
$group->save();
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$userGroups = $userA->groups()->get();
$this->assertSame(1, $userGroups->count());
$this->assertSame($group->id, $userGroups->first()->id);
$userB->delete();
$this->assertSame(['test@gmail.com'], $group->fresh()->members);
// Twice, one for save() and one for delete() above
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2);
}
/**
* Test handling negative balance on user deletion
*/
public function testDeleteWithNegativeBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = -1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
\App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
$user->delete();
$reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertSame(-1000, $reseller_wallet->fresh()->balance);
$this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Deleted user {$user->email}", $trans->description);
$this->assertSame(-1000, $trans->amount);
$this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type);
}
/**
* Test handling positive balance on user deletion
*/
public function testDeleteWithPositiveBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = 1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
$user->delete();
$this->assertSame(0, $reseller_wallet->fresh()->balance);
}
/**
* Tests for User::aliasExists()
*/
public function testAliasExists(): void
{
$this->assertTrue(User::aliasExists('jack.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('j.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('john@kolab.org'));
}
/**
* Tests for User::emailExists()
*/
public function testEmailExists(): void
{
$this->assertFalse(User::emailExists('jack.daniels@kolab.org'));
$this->assertFalse(User::emailExists('j.daniels@kolab.org'));
$this->assertTrue(User::emailExists('john@kolab.org'));
$user = User::emailExists('john@kolab.org', true);
$this->assertSame('john@kolab.org', $user->email);
}
/**
* Tests for User::findByEmail()
*/
public function testFindByEmail(): void
{
$user = $this->getTestUser('john@kolab.org');
$result = User::findByEmail('john');
$this->assertNull($result);
$result = User::findByEmail('non-existing@email.com');
$this->assertNull($result);
$result = User::findByEmail('john@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
// Use an alias
$result = User::findByEmail('john.doe@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
+ Queue::fake();
+
// A case where two users have the same alias
$ned = $this->getTestUser('ned@kolab.org');
$ned->setAliases(['joe.monster@kolab.org']);
$result = User::findByEmail('joe.monster@kolab.org');
$this->assertNull($result);
$ned->setAliases([]);
// TODO: searching by external email (setting)
$this->markTestIncomplete();
}
/**
* Test User::name()
*/
public function testName(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$this->assertSame('', $user->name());
$this->assertSame($user->tenant->title . ' User', $user->name(true));
$user->setSetting('first_name', 'First');
$this->assertSame('First', $user->name());
$this->assertSame('First', $user->name(true));
$user->setSetting('last_name', 'Last');
$this->assertSame('First Last', $user->name());
$this->assertSame('First Last', $user->name(true));
}
/**
* Test user restoring
*/
public function testRestore(): void
{
Queue::fake();
// Test an account with users and domain
$userA = $this->getTestUser('UserAccountA@UserAccount.com', [
'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED,
]);
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domainA = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$domainB = $this->getTestDomain('UserAccountAdd.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domainA->assignPackage($package_domain, $userA);
$domainB->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$now = \Carbon\Carbon::now();
$wallet_id = $userA->wallets->first()->id;
// add an extra storage entitlement
$ent1 = \App\Entitlement::create([
'wallet_id' => $wallet_id,
'sku_id' => $storage_sku->id,
'cost' => 0,
'entitleable_id' => $userA->id,
'entitleable_type' => User::class,
]);
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id);
// First delete the user
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainA->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domainA->isDeleted());
// Backdate one storage entitlement (it's not expected to be restored)
\App\Entitlement::withTrashed()->where('id', $ent1->id)
->update(['deleted_at' => $now->copy()->subMinutes(2)]);
// Backdate entitlements to assert that they were restored with proper updated_at timestamp
\App\Entitlement::withTrashed()->where('wallet_id', $wallet_id)
->update(['updated_at' => $now->subMinutes(10)]);
Queue::fake();
// Then restore it
$userA->restore();
$userA->refresh();
$this->assertFalse($userA->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userA->isSuspended());
$this->assertFalse($userA->isLdapReady());
$this->assertFalse($userA->isImapReady());
$this->assertTrue($userA->isActive());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($domainA->fresh()->trashed());
// Assert entitlements
$this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage
$this->assertTrue($ent1->fresh()->trashed());
$entitlementsA->get()->each(function ($ent) {
$this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5)));
});
// We expect only CreateJob + UpdateJob pair for both user and domain.
// Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method
// is implemented we cannot skip the UpdateJob in any way.
// I don't want to overwrite this method, the extra job shouldn't do any harm.
$this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line
Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($userA) {
return $userA->id === TestCase::getObjectProperty($job, 'userId');
}
);
Queue::assertPushedWithChain(
\App\Jobs\User\CreateJob::class,
[
\App\Jobs\User\VerifyJob::class,
]
);
}
/**
* Tests for UserAliasesTrait::setAliases()
*/
public function testSetAliases(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$this->assertCount(0, $user->aliases->all());
+ $user->tenant->setSetting('pgp.enable', 1);
+
// Add an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
+ Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
+
+ $user->tenant->setSetting('pgp.enable', 0);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Add another alias
$user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
+ Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]->alias);
$this->assertSame('useralias2@useraccount.com', $aliases[1]->alias);
+ $user->tenant->setSetting('pgp.enable', 1);
+
// Remove an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
+ $user->tenant->setSetting('pgp.enable', 0);
+
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
+ Queue::assertPushed(\App\Jobs\PGP\KeyUnregisterJob::class, 1);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Remove all aliases
$user->setAliases([]);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4);
$this->assertCount(0, $user->aliases()->get());
}
/**
* Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings()
*/
public function testUserSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0);
// Test default settings
// Note: Technicly this tests UserObserver::created() behavior
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(2, $all_settings);
$this->assertSame('country', $all_settings[0]->key);
$this->assertSame('CH', $all_settings[0]->value);
$this->assertSame('currency', $all_settings[1]->key);
$this->assertSame('CHF', $all_settings[1]->value);
// Add a setting
$user->setSetting('first_name', 'Firstname');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname', $user->getSetting('first_name'));
$this->assertSame('Firstname', $user->fresh()->getSetting('first_name'));
// Update a setting
$user->setSetting('first_name', 'Firstname1');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname1', $user->getSetting('first_name'));
$this->assertSame('Firstname1', $user->fresh()->getSetting('first_name'));
// Delete a setting (null)
$user->setSetting('first_name', null);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Delete a setting (empty string)
$user->setSetting('first_name', 'Firstname1');
$user->setSetting('first_name', '');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Set multiple settings at once
$user->setSettings([
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'country' => null,
]);
// TODO: This really should create a single UserUpdate job, not 3
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname2', $user->getSetting('first_name'));
$this->assertSame('Firstname2', $user->fresh()->getSetting('first_name'));
$this->assertSame('Lastname2', $user->getSetting('last_name'));
$this->assertSame('Lastname2', $user->fresh()->getSetting('last_name'));
$this->assertSame(null, $user->getSetting('country'));
$this->assertSame(null, $user->fresh()->getSetting('country'));
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(3, $all_settings);
// Test getSettings() method
$this->assertSame(
[
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'unknown' => null,
],
$user->getSettings(['first_name', 'last_name', 'unknown'])
);
}
/**
* Tests for User::users()
*/
public function testUsers(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$wallet = $john->wallets()->first();
$users = $john->users()->orderBy('email')->get();
$this->assertCount(4, $users);
$this->assertEquals($jack->id, $users[0]->id);
$this->assertEquals($joe->id, $users[1]->id);
$this->assertEquals($john->id, $users[2]->id);
$this->assertEquals($ned->id, $users[3]->id);
$this->assertSame($wallet->id, $users[0]->wallet_id);
$this->assertSame($wallet->id, $users[1]->wallet_id);
$this->assertSame($wallet->id, $users[2]->wallet_id);
$this->assertSame($wallet->id, $users[3]->wallet_id);
$users = $jack->users()->orderBy('email')->get();
$this->assertCount(0, $users);
$users = $ned->users()->orderBy('email')->get();
$this->assertCount(4, $users);
}
public function testWallets(): void
{
$this->markTestIncomplete();
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Apr 18, 10:05 AM (1 h, 32 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
436127
Default Alt Text
(101 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment