diff --git a/bin/quickstart.sh b/bin/quickstart.sh index 4aa5c848..84ce7c0a 100755 --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -1,94 +1,96 @@ #!/bin/bash set -e function die() { echo "$1" exit 1 } rpm -qv composer >/dev/null 2>&1 || \ test ! -z "$(which composer 2>/dev/null)" || \ die "Is composer installed?" rpm -qv docker-compose >/dev/null 2>&1 || \ test ! -z "$(which docker-compose 2>/dev/null)" || \ die "Is docker-compose installed?" rpm -qv npm >/dev/null 2>&1 || \ test ! -z "$(which npm 2>/dev/null)" || \ die "Is npm installed?" rpm -qv php >/dev/null 2>&1 || \ test ! -z "$(which php 2>/dev/null)" || \ die "Is php installed?" rpm -qv php-ldap >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep ldap)" || \ die "Is php-ldap installed?" rpm -qv php-mysqlnd >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep mysql)" || \ die "Is php-mysqlnd installed?" test ! -z "$(php --modules | grep swoole)" || \ die "Is swoole installed?" base_dir=$(dirname $(dirname $0)) docker pull docker.io/kolab/centos7:latest docker-compose down --remove-orphans docker-compose build pushd ${base_dir}/src/ if [ ! -f ".env" ]; then cp .env.example .env fi if [ -f ".env.local" ]; then # Ensure there's a line ending echo "" >> .env cat .env.local >> .env fi popd bin/regen-certs docker-compose up -d coturn kolab mariadb openvidu kurento-media-server proxy redis pushd ${base_dir}/src/ rm -rf vendor/ composer.lock php -dmemory_limit=-1 /bin/composer install npm install find bootstrap/cache/ -type f ! -name ".gitignore" -delete ./artisan key:generate ./artisan jwt:secret -f ./artisan clear-compiled ./artisan cache:clear ./artisan horizon:install if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') ./artisan dusk:chrome-driver ${chver} fi if [ ! -f 'resources/countries.php' ]; then ./artisan data:countries fi npm run dev popd docker-compose up -d worker pushd ${base_dir}/src/ rm -rf database/database.sqlite ./artisan db:ping --wait php -dmemory_limit=512M ./artisan migrate:refresh --seed +./artisan data:import +./artisan swoole:http stop >/dev/null 2>&1 || : ./artisan swoole:http start popd diff --git a/extras/kolab_policy_greylist.py b/extras/kolab_policy_greylist.py new file mode 100755 index 00000000..0b627a40 --- /dev/null +++ b/extras/kolab_policy_greylist.py @@ -0,0 +1,89 @@ +#!/usr/bin/python3 +""" +An example implementation of a policy service. +""" + +import json +import time +import sys + +import requests + + +def read_request_input(): + """ + Read a single policy request from sys.stdin, and return a dictionary + containing the request. + """ + start_time = time.time() + + policy_request = {} + end_of_request = False + + while not end_of_request: + if (time.time() - start_time) >= 10: + sys.exit(0) + + request_line = sys.stdin.readline() + + if request_line.strip() == '': + if 'request' in policy_request: + end_of_request = True + else: + request_line = request_line.strip() + request_key = request_line.split('=')[0] + request_value = '='.join(request_line.split('=')[1:]) + + policy_request[request_key] = request_value + + return policy_request + + +if __name__ == "__main__": + TOKEN = 'abcdef' + # URL = 'https://services.kolabnow.com/api/webhooks/greylist' + # URL = 'http://127.0.0.1:8000/api/webhooks/greylist' + URL = 'https://kanarip.dev.kolab.io/api/webhooks/greylist' + + # Start the work + while True: + REQUEST = read_request_input() + + # print("timestamp={0}".format(REQUEST['timestamp'])) + + try: + RESPONSE = requests.post( + URL, + data=REQUEST, + headers={'X-Token': TOKEN}, + verify=True + ) + # pylint: disable=broad-except + except Exception: + print("action=DEFER_IF_PERMIT Temporary error, try again later.") + sys.exit(1) + + try: + R = json.loads(RESPONSE.text) + # pylint: disable=broad-except + except Exception: + sys.exit(1) + + if 'prepend' in R: + for prepend in R['prepend']: + print("action=PREPEND {0}".format(prepend)) + + if RESPONSE.ok: + print( + "action={0}\n".format(R['response']) + ) + + sys.stdout.flush() + else: + print( + "action={0} {1}\n".format(R['response'], R['reason']) + ) + + sys.stdout.flush() + + sys.exit(0) diff --git a/extras/kolab_policy_spf.py b/extras/kolab_policy_spf.py new file mode 100755 index 00000000..4d6796a3 --- /dev/null +++ b/extras/kolab_policy_spf.py @@ -0,0 +1,90 @@ +#!/usr/bin/python3 +""" +This is the implementation of a (postfix) MTA policy service to enforce the +Sender Policy Framework. +""" + +import json +import time +import sys + +import requests + + +def read_request_input(): + """ + Read a single policy request from sys.stdin, and return a dictionary + containing the request. + """ + start_time = time.time() + + policy_request = {} + end_of_request = False + + while not end_of_request: + if (time.time() - start_time) >= 10: + sys.exit(0) + + request_line = sys.stdin.readline() + + if request_line.strip() == '': + if 'request' in policy_request: + end_of_request = True + else: + request_line = request_line.strip() + request_key = request_line.split('=')[0] + request_value = '='.join(request_line.split('=')[1:]) + + policy_request[request_key] = request_value + + return policy_request + + +if __name__ == "__main__": + TOKEN = 'abcdef' + # URL = 'https://services.kolabnow.com/api/webhooks/spf' + # URL = 'http://127.0.0.1:8000/api/webhooks/spf' + URL = 'https://kanarip.dev.kolab.io/api/webhooks/spf' + + # Start the work + while True: + REQUEST = read_request_input() + + # print("timestamp={0}".format(REQUEST['timestamp'])) + + try: + RESPONSE = requests.post( + URL, + data=REQUEST, + headers={'X-Token': TOKEN}, + verify=True + ) + # pylint: disable=broad-except + except Exception: + print("action=DEFER_IF_PERMIT Temporary error, try again later.") + sys.exit(1) + + try: + R = json.loads(RESPONSE.text) + # pylint: disable=broad-except + except Exception: + sys.exit(1) + + if 'prepend' in R: + for prepend in R['prepend']: + print("action=PREPEND {0}".format(prepend)) + + if RESPONSE.ok: + print( + "action={0}\n".format(R['response']) + ) + + sys.stdout.flush() + else: + print( + "action={0} {1}\n".format(R['response'], R['reason']) + ) + + sys.stdout.flush() + + sys.exit(0) diff --git a/src/.gitignore b/src/.gitignore index ad4636b4..c89f4357 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,24 +1,26 @@ *.swp database/database.sqlite node_modules/ package-lock.json public/css/*.css public/hot public/js/*.js public/storage/ storage/*.key +storage/*.log +storage/*-????-??-??* storage/export/ tests/report/ vendor .env .env.backup .env.local .env.testing .phpunit.result.cache Homestead.json Homestead.yaml npm-debug.log yarn-error.log composer.lock resources/countries.php resources/build/js/ diff --git a/src/app/Console/Commands/User/GreylistCommand.php b/src/app/Console/Commands/User/GreylistCommand.php new file mode 100644 index 00000000..e2e40d94 --- /dev/null +++ b/src/app/Console/Commands/User/GreylistCommand.php @@ -0,0 +1,73 @@ +argument('user'); + $recipientHash = hash('sha256', $recipientAddress); + + $lastConnect = \App\Greylist\Connect::where('recipient_hash', $recipientHash) + ->orderBy('updated_at', 'desc') + ->first(); + + if ($lastConnect) { + $timestamp = $lastConnect->updated_at->copy(); + $this->info("Going from timestamp (last connect) {$timestamp}"); + } else { + $timestamp = \Carbon\Carbon::now(); + $this->info("Going from timestamp (now) {$timestamp}"); + } + + + \App\Greylist\Connect::where('recipient_hash', $recipientHash) + ->where('greylisting', true) + ->whereDate('updated_at', '>=', $timestamp->copy()->subDays(7)) + ->orderBy('created_at')->each( + function ($connect) { + $this->info( + sprintf( + "From %s@%s since %s", + $connect->sender_local, + $connect->sender_domain, + $connect->created_at + ) + ); + } + ); + } +} diff --git a/src/app/Greylist/Connect.php b/src/app/Greylist/Connect.php new file mode 100644 index 00000000..0c6e4662 --- /dev/null +++ b/src/app/Greylist/Connect.php @@ -0,0 +1,62 @@ +recipient_type == \App\Domain::class) { + return $this->recipient; + } + + return null; + } + + // determine if the sender is a penpal of the recipient. + public function isPenpal() + { + return false; + } + + public function user() + { + if ($this->recipient_type == \App\User::class) { + return $this->recipient; + } + + return null; + } + + public function net() + { + return $this->morphTo(); + } + + public function recipient() + { + return $this->morphTo(); + } +} diff --git a/src/app/Greylist/Request.php b/src/app/Greylist/Request.php new file mode 100644 index 00000000..ef95e75f --- /dev/null +++ b/src/app/Greylist/Request.php @@ -0,0 +1,299 @@ +request = $request; + + if (array_key_exists('timestamp', $this->request)) { + $this->timestamp = \Carbon\Carbon::parse($this->request['timestamp']); + } else { + $this->timestamp = \Carbon\Carbon::now(); + } + } + + public function headerGreylist() + { + if ($this->whitelist) { + if ($this->whitelist->sender_local) { + return sprintf( + "Received-Greylist: sender %s whitelisted since %s", + $this->sender, + $this->whitelist->created_at->toDateString() + ); + } + + return sprintf( + "Received-Greylist: domain %s from %s whitelisted since %s (UTC)", + $this->senderDomain, + $this->request['client_address'], + $this->whitelist->created_at->toDateTimeString() + ); + } + + $connect = $this->findConnectsCollection()->orderBy('created_at')->first(); + + if ($connect) { + return sprintf( + "Received-Greylist: greylisted from %s until %s.", + $connect->created_at, + $this->timestamp + ); + } + + return "Received-Greylist: no opinion here"; + } + + public function shouldDefer() + { + $deferIfPermit = true; + + list($this->netID, $this->netType) = \App\Utils::getNetFromAddress($this->request['client_address']); + + if (!$this->netID) { + return true; + } + + $recipient = $this->recipientFromRequest(); + + $this->sender = $this->senderFromRequest(); + + list($this->senderLocal, $this->senderDomain) = explode('@', $this->sender); + + $entry = $this->findConnectsCollectionRecent()->orderBy('updated_at')->first(); + + if (!$entry) { + // purge all entries to avoid a unique constraint violation. + $this->findConnectsCollection()->delete(); + + $entry = \App\Greylist\Connect::create( + [ + 'sender_local' => $this->senderLocal, + 'sender_domain' => $this->senderDomain, + 'net_id' => $this->netID, + 'net_type' => $this->netType, + 'recipient_hash' => $this->recipientHash, + 'recipient_id' => $this->recipientID, + 'recipient_type' => $this->recipientType, + 'connect_count' => 1, + 'created_at' => $this->timestamp, + 'updated_at' => $this->timestamp + ] + ); + } + + // see if all recipients and their domains are opt-outs + $enabled = false; + + if ($recipient) { + $setting = \App\Greylist\Setting::where( + [ + 'object_id' => $this->recipientID, + 'object_type' => $this->recipientType, + 'key' => 'greylist_enabled' + ] + )->first(); + + if (!$setting) { + $setting = \App\Greylist\Setting::where( + [ + 'object_id' => $recipient->domain()->id, + 'object_type' => \App\Domain::class, + 'key' => 'greylist_enabled' + ] + )->first(); + + if (!$setting) { + $enabled = true; + } else { + if ($setting->{'value'} !== 'false') { + $enabled = true; + } + } + } else { + if ($setting->{'value'} !== 'false') { + $enabled = true; + } + } + } else { + $enabled = true; + } + + // the following block is to maintain statistics and state ... + $entries = \App\Greylist\Connect::where( + [ + 'sender_domain' => $this->senderDomain, + 'net_id' => $this->netID, + 'net_type' => $this->netType + ] + ) + ->whereDate('updated_at', '>=', $this->timestamp->copy()->subDays(7)); + + // determine if the sender domain is a whitelist from this network + $this->whitelist = \App\Greylist\Whitelist::where( + [ + 'sender_domain' => $this->senderDomain, + 'net_id' => $this->netID, + 'net_type' => $this->netType + ] + )->first(); + + if ($this->whitelist) { + if ($this->whitelist->updated_at < $this->timestamp->copy()->subMonthsWithoutOverflow(1)) { + $this->whitelist->delete(); + } else { + $this->whitelist->updated_at = $this->timestamp; + $this->whitelist->save(['timestamps' => false]); + + $entries->update( + [ + 'greylisting' => false, + 'updated_at' => $this->timestamp + ] + ); + + return false; + } + } else { + if ($entries->count() >= 5) { + $this->whitelist = \App\Greylist\Whitelist::create( + [ + 'sender_domain' => $this->senderDomain, + 'net_id' => $this->netID, + 'net_type' => $this->netType, + 'created_at' => $this->timestamp, + 'updated_at' => $this->timestamp + ] + ); + + $entries->update( + [ + 'greylisting' => false, + 'updated_at' => $this->timestamp + ] + ); + } + } + + // TODO: determine if the sender (individual) is a whitelist + + // TODO: determine if the sender is a penpal of any of the recipients. First recipient wins. + + if (!$enabled) { + return false; + } + + // determine if the sender, net and recipient combination has existed before, for each recipient + // any one recipient matching should supersede the other recipients not having matched + $connect = \App\Greylist\Connect::where( + [ + 'sender_local' => $this->senderLocal, + 'sender_domain' => $this->senderDomain, + 'net_id' => $this->netID, + 'net_type' => $this->netType, + 'recipient_id' => $this->recipientID, + 'recipient_type' => $this->recipientType, + ] + ) + ->whereDate('updated_at', '>=', $this->timestamp->copy()->subMonthsWithoutOverflow(1)) + ->orderBy('updated_at') + ->first(); + + if (!$connect) { + $connect = \App\Greylist\Connect::create( + [ + 'sender_local' => $this->senderLocal, + 'sender_domain' => $this->senderDomain, + 'net_id' => $this->netID, + 'net_type' => $this->netType, + 'recipient_id' => $this->recipientID, + 'recipient_type' => $this->recipientType, + 'connect_count' => 0, + 'created_at' => $this->timestamp, + 'updated_at' => $this->timestamp + ] + ); + } + + $connect->connect_count += 1; + + // TODO: The period of time for which the greylisting persists is configurable. + if ($connect->created_at < $this->timestamp->copy()->subMinutes(5)) { + $deferIfPermit = false; + + $connect->greylisting = false; + } + + $connect->save(); + + return $deferIfPermit; + } + + private function findConnectsCollection() + { + $collection = \App\Greylist\Connect::where( + [ + 'sender_local' => $this->senderLocal, + 'sender_domain' => $this->senderDomain, + 'net_id' => $this->netID, + 'net_type' => $this->netType, + 'recipient_id' => $this->recipientID, + 'recipient_type' => $this->recipientType + ] + ); + + return $collection; + } + + private function findConnectsCollectionRecent() + { + return $this->findConnectsCollection() + ->where('updated_at', '>=', $this->timestamp->copy()->subDays(7)); + } + + private function recipientFromRequest() + { + $recipients = \App\Utils::findObjectsByRecipientAddress($this->request['recipient']); + + if (sizeof($recipients) > 1) { + \Log::warning( + "Only taking the first recipient from the request in to account for {$this->request['recipient']}" + ); + } + + if (count($recipients) >= 1) { + $recipient = $recipients[0]; + $this->recipientID = $recipient->id; + $this->recipientType = get_class($recipient); + } else { + $recipient = null; + } + + $this->recipientHash = hash('sha256', $this->request['recipient']); + + return $recipient; + } + + public function senderFromRequest() + { + return \App\Utils::normalizeAddress($this->request['sender']); + } +} diff --git a/src/app/Greylist/Setting.php b/src/app/Greylist/Setting.php new file mode 100644 index 00000000..e12ec630 --- /dev/null +++ b/src/app/Greylist/Setting.php @@ -0,0 +1,17 @@ +input(); + + list($local, $domainName) = explode('@', $data['recipient']); + + $request = new \App\Greylist\Request($data); + + $shouldDefer = $request->shouldDefer(); + + if ($shouldDefer) { + return response()->json( + ['response' => 'DEFER_IF_PERMIT', 'reason' => "Greylisted for 5 minutes. Try again later."], + 403 + ); + } + + $prependGreylist = $request->headerGreylist(); + + $result = [ + 'response' => 'DUNNO', + 'prepend' => [$prependGreylist] + ]; + + return response()->json($result, 200); + } + + /* + * Apply the sender policy framework to a request. + * + * @return \Illuminate\Http\JsonResponse + */ + public function senderPolicyFramework() + { + $data = \request()->input(); + + list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']); + list($senderLocal, $senderDomain) = explode('@', $data['sender']); + + // This network can not be recognized. + if (!$netID) { + return response()->json( + [ + 'response' => 'DEFER_IF_PERMIT', + 'reason' => 'Temporary error. Please try again later.' + ], + 403 + ); + } + + // Compose the cache key we want. + $cacheKey = "{$netType}_{$netID}_{$senderDomain}"; + + $result = \App\SPF\Cache::get($cacheKey); + + if (!$result) { + $environment = new \SPFLib\Check\Environment( + $data['client_address'], + $data['client_name'], + $data['sender'] + ); + + $result = (new \SPFLib\Checker())->check($environment); + + \App\SPF\Cache::set($cacheKey, serialize($result)); + } else { + $result = unserialize($result); + } + + $fail = false; + + switch ($result->getCode()) { + case \SPFLib\Check\Result::CODE_ERROR_PERMANENT: + $fail = true; + $prependSPF = "Received-SPF: Permerror"; + break; + + case \SPFLib\Check\Result::CODE_ERROR_TEMPORARY: + $prependSPF = "Received-SPF: Temperror"; + break; + + case \SPFLib\Check\Result::CODE_FAIL: + $fail = true; + $prependSPF = "Received-SPF: Fail"; + break; + + case \SPFLib\Check\Result::CODE_SOFTFAIL: + $prependSPF = "Received-SPF: Softfail"; + break; + + case \SPFLib\Check\Result::CODE_NEUTRAL: + $prependSPF = "Received-SPF: Neutral"; + break; + + case \SPFLib\Check\Result::CODE_PASS: + $prependSPF = "Received-SPF: Pass"; + break; + + case \SPFLib\Check\Result::CODE_NONE: + $prependSPF = "Received-SPF: None"; + break; + } + + $prependSPF .= " identity=mailfrom;"; + $prependSPF .= " client-ip={$data['client_address']};"; + $prependSPF .= " helo={$data['client_name']};"; + $prependSPF .= " envelope-from={$data['sender']};"; + + if ($fail) { + // TODO: check the recipient's policy, such as using barracuda for anti-spam and anti-virus as a relay for + // inbound mail to a local recipient address. + $objects = \App\Utils::findObjectsByRecipientAddress($data['recipient']); + + if (!empty($objects)) { + // check if any of the recipient objects have whitelisted the helo, first one wins. + foreach ($objects as $object) { + if (method_exists($object, 'senderPolicyFrameworkWhitelist')) { + $result = $object->senderPolicyFrameworkWhitelist($data['client_name']); + + if ($result) { + $response = [ + 'response' => 'DUNNO', + 'prepend' => ["Received-SPF: Pass Check skipped at recipient's discretion"], + 'reason' => 'HELO name whitelisted' + ]; + + return response()->json($response, 200); + } + } + } + } + + $result = [ + 'response' => 'REJECT', + 'prepend' => [$prependSPF], + 'reason' => "Prohibited by Sender Policy Framework" + ]; + + return response()->json($result, 403); + } + + $result = [ + 'response' => 'DUNNO', + 'prepend' => [$prependSPF], + 'reason' => "Don't know" + ]; + + return response()->json($result, 200); + } +} diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php index 9d4a5085..f49d8a29 100644 --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -1,103 +1,103 @@ [ // \App\Http\Middleware\EncryptCookies::class, // \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, // \Illuminate\Session\Middleware\StartSession::class, // \Illuminate\Session\Middleware\AuthenticateSession::class, // \Illuminate\View\Middleware\ShareErrorsFromSession::class, // \App\Http\Middleware\VerifyCsrfToken::class, // \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'api' => [ - 'throttle:120,1', + //'throttle:120,1', 'bindings', ], ]; /** * The application's route middleware. * * These middleware may be assigned to groups or used individually. * * @var array */ protected $routeMiddleware = [ 'admin' => \App\Http\Middleware\AuthenticateAdmin::class, 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'reseller' => \App\Http\Middleware\AuthenticateReseller::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, ]; /** * The priority-sorted list of middleware. * * This forces non-global middleware to always be in the given order. * * @var array */ protected $middlewarePriority = [ \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\AuthenticateAdmin::class, \App\Http\Middleware\AuthenticateReseller::class, \App\Http\Middleware\Authenticate::class, \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Auth\Middleware\Authorize::class, ]; /** * Handle an incoming HTTP request. * * @param \Illuminate\Http\Request $request HTTP Request object * * @return \Illuminate\Http\Response */ public function handle($request) { // Overwrite the http request object return parent::handle(Request::createFrom($request)); } } diff --git a/src/app/IP4Net.php b/src/app/IP4Net.php index 1e57ba77..c55d8e97 100644 --- a/src/app/IP4Net.php +++ b/src/app/IP4Net.php @@ -1,19 +1,40 @@ = INET_ATON(?) + ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1 + "; + + $results = DB::select($query, [$ip, $ip]); + + if (sizeof($results) == 0) { + return null; + } + + return \App\IP4Net::find($results[0]->id); + } } diff --git a/src/app/SPF/Cache.php b/src/app/SPF/Cache.php new file mode 100644 index 00000000..dff3f201 --- /dev/null +++ b/src/app/SPF/Cache.php @@ -0,0 +1,40 @@ +belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'admin') { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } return $this->canDelete($object); } /** * Return the \App\Domain for this user. * * @return \App\Domain|null */ public function domain() { list($local, $domainName) = explode('@', $this->email); $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); return $domain; } /** * List the domains to which this user is entitled. * Note: Active public domains are also returned (for the user tenant). * * @return Domain[] List of Domain objects */ public function domains(): array { if ($this->tenant_id) { $domains = Domain::where('tenant_id', $this->tenant_id); } else { $domains = Domain::withEnvTenant(); } $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) ->get() ->all(); foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } return $domains; } /** * The user entitlement. * * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id') ->where('entitleable_type', User::class); } /** * Find whether an email address exists as a user (including deleted users). * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $user = self::withTrashed()->where('email', $email)->first(); if ($user) { return $return_user ? $user : true; } return false; } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return Group::select(['groups.*', 'entitlements.wallet_id']) ->distinct() ->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', Group::class); } /** * Check if user has an entitlement for the specified SKU. * * @param string $title The SKU title * * @return bool True if specified SKU entitlement exists */ public function hasSku($title): bool { $sku = Sku::where('title', $title)->first(); if (!$sku) { return false; } return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $firstname = $this->getSetting('first_name'); $lastname = $this->getSetting('last_name'); $name = trim($firstname . ' ' . $lastname); if (empty($name) && $fallback) { return \config('app.name') . ' User'; } return $name; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return User Self */ public function removeSku(Sku $sku, int $count = 1): User { $entitlements = $this->entitlements() ->where('sku_id', $sku->id) ->orderBy('cost', 'desc') ->orderBy('created_at') ->get(); $entitlements_count = count($entitlements); foreach ($entitlements as $entitlement) { if ($entitlements_count <= $sku->units_free) { continue; } if ($count > 0) { $entitlement->delete(); $entitlements_count--; $count--; } } return $this; } + public function senderPolicyFrameworkWhitelist($clientName) + { + $setting = $this->getSetting('spf_whitelist'); + + if (!$setting) { + return false; + } + + $whitelist = json_decode($setting); + + $matchFound = false; + + foreach ($whitelist as $entry) { + if (substr($entry, 0, 1) == '/') { + $match = preg_match($entry, $clientName); + + if ($match) { + $matchFound = true; + } + + continue; + } + + if (substr($entry, 0, 1) == '.') { + if (substr($clientName, (-1 * strlen($entry))) == $entry) { + $matchFound = true; + } + + continue; + } + + if ($entry == $clientName) { + $matchFound = true; + continue; + } + } + + return $matchFound; + } + /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * The tenant for this user account. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function tenant() { return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', User::class); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { $entitlement = $this->entitlement()->withTrashed()->orderBy('created_at', 'desc')->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 86cffb74..4a941f64 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,396 +1,523 @@ = INET_ATON(?) ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1 "; } else { $query = " SELECT id FROM ip6nets WHERE INET6_ATON(net_number) <= INET6_ATON(?) AND INET6_ATON(net_broadcast) >= INET6_ATON(?) ORDER BY INET6_ATON(net_number), net_mask DESC LIMIT 1 "; } $nets = \Illuminate\Support\Facades\DB::select($query, [$ip, $ip]); if (sizeof($nets) > 0) { return $nets[0]->country; } return 'CH'; } /** * Return the country ISO code for the current request. */ public static function countryForRequest() { $request = \request(); $ip = $request->ip(); return self::countryForIP($ip); } /** * Shortcut to creating a progress bar of a particular format with a particular message. * * @param \Illuminate\Console\OutputStyle $output Console output object * @param int $count Number of progress steps * @param string $message The description * * @return \Symfony\Component\Console\Helper\ProgressBar */ public static function createProgressBar($output, $count, $message = null) { $bar = $output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage($message . " ..."); } $bar->start(); return $bar; } /** * Return the number of days in the month prior to this one. * * @return int */ public static function daysInLastMonth() { $start = new Carbon('first day of last month'); $end = new Carbon('last day of last month'); return $start->diffInDays($end) + 1; } /** * Download a file from the interwebz and store it locally. * * @param string $source The source location * @param string $target The target location * @param bool $force Force the download (and overwrite target) * * @return void */ public static function downloadFile($source, $target, $force = false) { if (is_file($target) && !$force) { return; } \Log::info("Retrieving {$source}"); $fp = fopen($target, 'w'); $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $source); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_FILE, $fp); curl_exec($curl); if (curl_errno($curl)) { \Log::error("Request error on {$source}: " . curl_error($curl)); curl_close($curl); fclose($fp); unlink($target); return; } curl_close($curl); fclose($fp); } + /** + * Find an object that is the recipient for the specified address. + * + * @param string $address + * + * @return array + */ + public static function findObjectsByRecipientAddress($address) + { + $address = \App\Utils::normalizeAddress($address); + + list($local, $domainName) = explode('@', $address); + + $domain = \App\Domain::where('namespace', $domainName)->first(); + + if (!$domain) { + return []; + } + + $user = \App\User::where('email', $address)->first(); + + if ($user) { + return [$user]; + } + + $userAliases = \App\UserAlias::where('alias', $address)->get(); + + if (count($userAliases) > 0) { + $users = []; + + foreach ($userAliases as $userAlias) { + $users[] = $userAlias->user; + } + + return $users; + } + + $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get(); + + if (count($userAliases) > 0) { + $users = []; + + foreach ($userAliases as $userAlias) { + $users[] = $userAlias->user; + } + + return $users; + } + + return []; + } + + /** + * Generate a passphrase. Not intended for use in production, so limited to environments that are not production. + * + * @return string + */ + public static function generatePassphrase() + { + $alphaLow = 'abcdefghijklmnopqrstuvwxyz'; + $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $num = '0123456789'; + $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<'; + + $source = $alphaLow . $alphaUp . $num . $stdSpecial; + + $result = ''; + + for ($x = 0; $x < 16; $x++) { + $result .= substr($source, rand(0, (strlen($source) - 1)), 1); + } + + return $result; + } + + /** + * Retrieve the network ID and Type from a client address + * + * @param string $clientAddress The IPv4 or IPv6 address. + * + * @return array An array of ID and class or null and null. + */ + public static function getNetFromAddress($clientAddress) + { + if (strpos($clientAddress, ':') === false) { + $net = \App\IP4Net::getNet($clientAddress); + + if ($net) { + return [$net->id, \App\IP4Net::class]; + } + } else { + $net = \App\IP6Net::getNet($clientAddress); + + if ($net) { + return [$net->id, \App\IP6Net::class]; + } + } + + return [null, null]; + } + /** * Calculate the broadcast address provided a net number and a prefix. * * @param string $net A valid IPv6 network number. * @param int $prefix The network prefix. * * @return string */ public static function ip6Broadcast($net, $prefix) { $netHex = bin2hex(inet_pton($net)); // Overwriting first address string to make sure notation is optimal $net = inet_ntop(hex2bin($netHex)); // Calculate the number of 'flexible' bits $flexbits = 128 - $prefix; // Build the hexadecimal string of the last address $lastAddrHex = $netHex; // We start at the end of the string (which is always 32 characters long) $pos = 31; while ($flexbits > 0) { // Get the character at this position $orig = substr($lastAddrHex, $pos, 1); // Convert it to an integer $origval = hexdec($orig); // OR it with (2^flexbits)-1, with flexbits limited to 4 at a time $newval = $origval | (pow(2, min(4, $flexbits)) - 1); // Convert it back to a hexadecimal character $new = dechex($newval); // And put that character back in the string $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1); // We processed one nibble, move to previous position $flexbits -= 4; $pos -= 1; } // Convert the hexadecimal string to a binary string $lastaddrbin = hex2bin($lastAddrHex); // And create an IPv6 address from the binary string $lastaddrstr = inet_ntop($lastaddrbin); return $lastaddrstr; } + /** + * Normalize an email address. + * + * This means to lowercase and strip components separated with recipient delimiters. + * + * @param string $address The address to normalize. + * + * @return string + */ + public static function normalizeAddress($address) + { + $address = strtolower($address); + + list($local, $domain) = explode('@', $address); + + if (strpos($local, '+') === false) { + return "{$local}@{$domain}"; + } + + $localComponents = explode('+', $local); + + $local = array_pop($localComponents); + + return "{$local}@{$domain}"; + } + /** * Provide all unique combinations of elements in $input, with order and duplicates irrelevant. * * @param array $input The input array of elements. * * @return array[] */ public static function powerSet(array $input): array { $output = []; for ($x = 0; $x < count($input); $x++) { self::combine($input, $x + 1, 0, [], 0, $output); } return $output; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a random string consisting of a quantity of segments of a certain length joined. * * Example: * * ```php * $roomName = strtolower(\App\Utils::randStr(3, 3, '-'); * // $roomName == '3qb-7cs-cjj' * ``` * * @param int $length The length of each segment * @param int $qty The quantity of segments * @param string $join The string to use to join the segments * * @return string */ public static function randStr($length, $qty = 1, $join = '') { $chars = env('SHORTCODE_CHARS', self::CHARS); $randStrs = []; for ($x = 0; $x < $qty; $x++) { $randStrs[$x] = []; for ($y = 0; $y < $length; $y++) { $randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)]; } shuffle($randStrs[$x]); $randStrs[$x] = implode('', $randStrs[$x]); } return implode($join, $randStrs); } /** * Returns a UUID in the form of an integer. * * @return integer */ public static function uuidInt(): int { $hex = Uuid::uuid4(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { return Uuid::uuid4()->toString(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create self URL * * @param string $route Route/Path * @todo Move this to App\Http\Controllers\Controller * * @return string Full URL */ public static function serviceUrl(string $route): string { $url = \config('app.public_url'); if (!$url) { $url = \config('app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo Move this to App\Http\Controllers\Controller * * @return array Configuration data */ public static function uiEnv(): array { $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); $opts = [ 'app.name', 'app.url', 'app.domain', 'app.theme', 'app.webmail_url', 'app.support_email', 'mail.from.address' ]; $env = \app('config')->getMany($opts); $env['countries'] = $countries ?: []; $env['view'] = 'root'; $env['jsapp'] = 'user.js'; if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; } elseif ($req_domain == "reseller.$sys_domain") { $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); $env['languages'] = \App\Http\Controllers\ContentController::locales(); $env['menu'] = \App\Http\Controllers\ContentController::menu(); return $env; } } diff --git a/src/composer.json b/src/composer.json index b1c874bf..c6c0a2df 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,88 +1,89 @@ { "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", "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-dump-server": "^1.0", "beyondcode/laravel-er-diagram-generator": "^1.3", "code-lts/doctum": "^5.1", "filp/whoops": "^2.0", "fzaninotto/faker": "^1.4", "kirschbaum-development/mail-intercept": "^0.2.4", "laravel/dusk": "~6.15.0", "mockery/mockery": "^1.0", "nunomaduro/larastan": "^0.7", "phpstan/phpstan": "^0.12", "phpunit/phpunit": "^8" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "database/factories", "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/cache.php b/src/config/cache.php index 46751e62..93adfa06 100644 --- a/src/config/cache.php +++ b/src/config/cache.php @@ -1,103 +1,103 @@ env('CACHE_DRIVER', 'file'), + 'default' => env('CACHE_DRIVER', 'redis'), /* |-------------------------------------------------------------------------- | Cache Stores |-------------------------------------------------------------------------- | | Here you may define all of the cache "stores" for your application as | well as their drivers. You may even define multiple stores for the | same cache driver to group types of items stored in your caches. | */ 'stores' => [ 'apc' => [ 'driver' => 'apc', ], 'array' => [ 'driver' => 'array', ], 'database' => [ 'driver' => 'database', 'table' => 'cache', 'connection' => null, ], 'file' => [ 'driver' => 'file', 'path' => storage_path('framework/cache/data'), ], 'memcached' => [ 'driver' => 'memcached', 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 'sasl' => [ env('MEMCACHED_USERNAME'), env('MEMCACHED_PASSWORD'), ], 'options' => [ // Memcached::OPT_CONNECT_TIMEOUT => 2000, ], 'servers' => [ [ 'host' => env('MEMCACHED_HOST', '127.0.0.1'), 'port' => env('MEMCACHED_PORT', 11211), 'weight' => 100, ], ], ], 'redis' => [ 'driver' => 'redis', 'connection' => 'cache', ], 'dynamodb' => [ 'driver' => 'dynamodb', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 'endpoint' => env('DYNAMODB_ENDPOINT'), ], ], /* |-------------------------------------------------------------------------- | Cache Key Prefix |-------------------------------------------------------------------------- | | When utilizing a RAM based store such as APC or Memcached, there might | be other applications utilizing the same cache. So, we'll specify a | value to get prefixed to all our keys so we can avoid collisions. | */ 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache'), ]; diff --git a/src/config/database.php b/src/config/database.php index 59b3a1ca..1e5c49f0 100644 --- a/src/config/database.php +++ b/src/config/database.php @@ -1,152 +1,152 @@ 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, ], ], /* |-------------------------------------------------------------------------- | 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_'), + '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/database/migrations/2020_10_18_091319_create_greylist_tables.php b/src/database/migrations/2020_10_18_091319_create_greylist_tables.php new file mode 100644 index 00000000..4a1589f9 --- /dev/null +++ b/src/database/migrations/2020_10_18_091319_create_greylist_tables.php @@ -0,0 +1,123 @@ +bigIncrements('id'); + $table->string('sender_local', 256); + $table->string('sender_domain', 256); + $table->string('recipient_hash', 64); + $table->bigInteger('recipient_id')->unsigned()->nullable(); + $table->string('recipient_type', 16)->nullable(); + $table->bigInteger('net_id'); + $table->string('net_type', 16); + $table->boolean('greylisting')->default(true); + $table->bigInteger('connect_count')->unsigned()->default(1); + $table->timestamps(); + + /** + * Index for recipient request. + */ + $table->index( + [ + 'sender_local', + 'sender_domain', + 'recipient_hash', + 'net_id', + 'net_type' + ], + 'ssrnn_idx' + ); + + /** + * Index for domain whitelist query. + */ + $table->index( + [ + 'sender_domain', + 'net_id', + 'net_type' + ], + 'snn_idx' + ); + + /** + * Index for updated_at + */ + $table->index('updated_at'); + + $table->unique( + ['sender_local', 'sender_domain', 'recipient_hash', 'net_id', 'net_type'], + 'ssrnn_unq' + ); + } + ); + + Schema::create( + 'greylist_penpals', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->bigInteger('local_id'); + $table->string('local_type', 16); + $table->string('remote_local', 128); + $table->string('remote_domain', 256); + $table->timestamps(); + } + ); + + Schema::create( + 'greylist_settings', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->bigInteger('object_id'); + $table->string('object_type', 16); + $table->string('key', 64); + $table->text('value'); + $table->timestamps(); + + $table->index(['object_id', 'object_type', 'key'], 'ook_idx'); + } + ); + + Schema::create( + 'greylist_whitelist', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('sender_local', 128)->nullable(); + $table->string('sender_domain', 256); + $table->bigInteger('net_id'); + $table->string('net_type', 16); + $table->timestamps(); + + $table->index(['sender_local', 'sender_domain', 'net_id', 'net_type'], 'ssnn_idx'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('greylist_connect'); + Schema::dropIfExists('greylist_penpals'); + Schema::dropIfExists('greylist_settings'); + Schema::dropIfExists('greylist_whitelist'); + } +} diff --git a/src/database/seeds/local/DomainSeeder.php b/src/database/seeds/local/DomainSeeder.php index 3b2bf128..ad5da695 100644 --- a/src/database/seeds/local/DomainSeeder.php +++ b/src/database/seeds/local/DomainSeeder.php @@ -1,81 +1,82 @@ $domain, 'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC ] ); } if (!in_array(\config('app.domain'), $domains)) { Domain::create( [ 'namespace' => \config('app.domain'), 'status' => DOMAIN::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC ] ); } $domains = [ 'example.com', 'example.net', 'example.org' ]; foreach ($domains as $domain) { Domain::create( [ 'namespace' => $domain, 'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_EXTERNAL ] ); } // example tenant domain, note that 'tenant_id' is not a fillable. $domain = Domain::create( [ 'namespace' => 'example-tenant.dev-local', 'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC ] ); $tenant = \App\Tenant::where('title', 'Sample Tenant')->first(); $domain->tenant_id = $tenant->id; $domain->save(); } } diff --git a/src/phpunit.xml b/src/phpunit.xml index e3d49429..af5a66c4 100644 --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -1,44 +1,45 @@ tests/Unit tests/Functional tests/Feature tests/Browser ./app + diff --git a/src/routes/api.php b/src/routes/api.php index f23ff8d1..115dfde5 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,217 +1,219 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), - 'prefix' => $prefix . 'api/webhooks', + 'prefix' => $prefix . 'api/webhooks' ], function () { + Route::post('greylist', 'API\V4\PolicyController@greylist'); Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); + Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); Route::group( [ 'domain' => 'reseller.' . \config('app.domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::apiResource('packages', API\V4\Reseller\PackagesController::class); Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); diff --git a/src/tests/Feature/Stories/GreylistTest.php b/src/tests/Feature/Stories/GreylistTest.php new file mode 100644 index 00000000..ec244d82 --- /dev/null +++ b/src/tests/Feature/Stories/GreylistTest.php @@ -0,0 +1,606 @@ +instance = $this->generateInstanceId(); + $this->clientAddress = '212.103.80.148'; + + $this->net = \App\IP4Net::getNet($this->clientAddress); + + DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';"); + DB::delete("DELETE FROM greylist_settings;"); + DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';"); + } + + public function tearDown(): void + { + DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';"); + DB::delete("DELETE FROM greylist_settings;"); + DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';"); + + parent::tearDown(); + } + + public function testWithTimestamp() + { + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress, + 'client_name' => 'some.mx', + 'timestamp' => \Carbon\Carbon::now()->subDays(7)->toString() + ] + ); + + $timestamp = $this->getObjectProperty($request, 'timestamp'); + + $this->assertTrue( + \Carbon\Carbon::parse($timestamp, 'UTC') < \Carbon\Carbon::now() + ); + } + + public function testNoNet() + { + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => '127.128.129.130', + 'client_name' => 'some.mx' + ] + ); + + $this->assertTrue($request->shouldDefer()); + } + + public function testIp6Net() + { + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => '2a00:1450:400a:803::2005', + 'client_name' => 'some.mx' + ] + ); + + $this->assertTrue($request->shouldDefer()); + } + + // public function testMultiRecipientThroughAlias() {} + + public function testWhitelistNew() + { + $whitelist = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); + + $this->assertNull($whitelist); + + for ($i = 0; $i < 5; $i++) { + $request = new \App\Greylist\Request( + [ + 'sender' => "someone{$i}@sender.domain", + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress, + 'client_name' => 'some.mx', + 'timestamp' => \Carbon\Carbon::now()->subDays(1) + ] + ); + + $this->assertTrue($request->shouldDefer()); + } + + $whitelist = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); + + $this->assertNotNull($whitelist); + + $request = new \App\Greylist\Request( + [ + 'sender' => "someone5@sender.domain", + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress, + 'client_name' => 'some.mx', + 'timestamp' => \Carbon\Carbon::now()->subDays(1) + ] + ); + + $this->assertFalse($request->shouldDefer()); + } + + // public function testWhitelistedHit() {} + + public function testWhitelistStale() + { + $whitelist = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); + + $this->assertNull($whitelist); + + for ($i = 0; $i < 5; $i++) { + $request = new \App\Greylist\Request( + [ + 'sender' => "someone{$i}@sender.domain", + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress, + 'client_name' => 'some.mx', + 'timestamp' => \Carbon\Carbon::now()->subDays(1) + ] + ); + + $this->assertTrue($request->shouldDefer()); + } + + $whitelist = \App\Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); + + $this->assertNotNull($whitelist); + + $request = new \App\Greylist\Request( + [ + 'sender' => "someone5@sender.domain", + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress, + 'client_name' => 'some.mx', + 'timestamp' => \Carbon\Carbon::now()->subDays(1) + ] + ); + + $this->assertFalse($request->shouldDefer()); + + $whitelist->updated_at = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); + $whitelist->save(['timestamps' => false]); + + $this->assertTrue($request->shouldDefer()); + } + + // public function testWhitelistUpdate() {} + + public function testNew() + { + $data = [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress, + 'client_name' => 'some.mx' + ]; + + $response = $this->post('/api/webhooks/greylist', $data); + + $response->assertStatus(403); + } + + public function testRetry() + { + $connect = \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $this->domainOwner->email), + 'recipient_id' => $this->domainOwner->id, + 'recipient_type' => \App\User::class, + 'connect_count' => 1, + 'net_id' => $this->net->id, + 'net_type' => \App\IP4Net::class + ] + ); + + $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); + $connect->save(); + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress + ] + ); + + $this->assertFalse($request->shouldDefer()); + } + + public function testDomainDisabled() + { + $setting = \App\Greylist\Setting::create( + [ + 'object_id' => $this->domainHosted->id, + 'object_type' => \App\Domain::class, + 'key' => 'greylist_enabled', + 'value' => 'false' + ] + ); + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress + ] + ); + + $this->assertFalse($request->shouldDefer()); + } + + public function testDomainEnabled() + { + $connect = \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $this->domainOwner->email), + 'recipient_id' => $this->domainOwner->id, + 'recipient_type' => \App\User::class, + 'connect_count' => 1, + 'net_id' => \App\IP4Net::getNet('212.103.80.148')->id, + 'net_type' => \App\IP4Net::class + ] + ); + + $setting = \App\Greylist\Setting::create( + [ + 'object_id' => $this->domainHosted->id, + 'object_type' => \App\Domain::class, + 'key' => 'greylist_enabled', + 'value' => 'true' + ] + ); + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress + ] + ); + + $this->assertTrue($request->shouldDefer()); + + $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); + $connect->save(); + + $this->assertFalse($request->shouldDefer()); + } + + public function testDomainDisabledUserDisabled() + { + $connect = \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $this->domainOwner->email), + 'recipient_id' => $this->domainOwner->id, + 'recipient_type' => \App\User::class, + 'connect_count' => 1, + 'net_id' => $this->net->id, + 'net_type' => \App\IP4Net::class + ] + ); + + $settingDomain = \App\Greylist\Setting::create( + [ + 'object_id' => $this->domainHosted->id, + 'object_type' => \App\Domain::class, + 'key' => 'greylist_enabled', + 'value' => 'false' + ] + ); + + $settingUser = \App\Greylist\Setting::create( + [ + 'object_id' => $this->domainOwner->id, + 'object_type' => \App\User::class, + 'key' => 'greylist_enabled', + 'value' => 'false' + ] + ); + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress + ] + ); + + $this->assertFalse($request->shouldDefer()); + } + + public function testDomainDisabledUserEnabled() + { + $connect = \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $this->domainOwner->email), + 'recipient_id' => $this->domainOwner->id, + 'recipient_type' => \App\User::class, + 'connect_count' => 1, + 'net_id' => $this->net->id, + 'net_type' => \App\IP4Net::class + ] + ); + + $settingDomain = \App\Greylist\Setting::create( + [ + 'object_id' => $this->domainHosted->id, + 'object_type' => \App\Domain::class, + 'key' => 'greylist_enabled', + 'value' => 'false' + ] + ); + + $settingUser = \App\Greylist\Setting::create( + [ + 'object_id' => $this->domainOwner->id, + 'object_type' => \App\User::class, + 'key' => 'greylist_enabled', + 'value' => 'true' + ] + ); + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress + ] + ); + + $this->assertTrue($request->shouldDefer()); + + $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); + $connect->save(); + + $this->assertFalse($request->shouldDefer()); + } + + public function testInvalidDomain() + { + $connect = \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $this->domainOwner->email), + 'recipient_id' => 1234, + 'recipient_type' => \App\Domain::class, + 'connect_count' => 1, + 'net_id' => $this->net->id, + 'net_type' => \App\IP4Net::class + ] + ); + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => 'not.someone@that.exists', + 'client_address' => $this->clientAddress + ] + ); + + $this->assertTrue($request->shouldDefer()); + } + + public function testInvalidUser() + { + $connect = \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $this->domainOwner->email), + 'recipient_id' => 1234, + 'recipient_type' => \App\User::class, + 'connect_count' => 1, + 'net_id' => $this->net->id, + 'net_type' => \App\IP4Net::class + ] + ); + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => 'not.someone@that.exists', + 'client_address' => $this->clientAddress + ] + ); + + $this->assertTrue($request->shouldDefer()); + } + + public function testUserDisabled() + { + $connect = \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $this->domainOwner->email), + 'recipient_id' => $this->domainOwner->id, + 'recipient_type' => \App\User::class, + 'connect_count' => 1, + 'net_id' => $this->net->id, + 'net_type' => \App\IP4Net::class + ] + ); + + $setting = \App\Greylist\Setting::create( + [ + 'object_id' => $this->domainOwner->id, + 'object_type' => \App\User::class, + 'key' => 'greylist_enabled', + 'value' => 'false' + ] + ); + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress + ] + ); + + $this->assertFalse($request->shouldDefer()); + } + + public function testUserEnabled() + { + $connect = \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $this->domainOwner->email), + 'recipient_id' => $this->domainOwner->id, + 'recipient_type' => \App\User::class, + 'connect_count' => 1, + 'net_id' => $this->net->id, + 'net_type' => \App\IP4Net::class + ] + ); + + $setting = \App\Greylist\Setting::create( + [ + 'object_id' => $this->domainOwner->id, + 'object_type' => \App\User::class, + 'key' => 'greylist_enabled', + 'value' => 'true' + ] + ); + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress + ] + ); + + $this->assertTrue($request->shouldDefer()); + + $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); + $connect->save(); + + $this->assertFalse($request->shouldDefer()); + } + + public function testMultipleUsersAllDisabled() + { + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress + ] + ); + + foreach ($this->domainUsers as $user) { + \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $user->email), + 'recipient_id' => $user->id, + 'recipient_type' => \App\User::class, + 'connect_count' => 1, + 'net_id' => $this->net->id, + 'net_type' => \App\IP4Net::class + ] + ); + + \App\Greylist\Setting::create( + [ + 'object_id' => $user->id, + 'object_type' => \App\User::class, + 'key' => 'greylist_enabled', + 'value' => 'false' + ] + ); + + if ($user->email == $this->domainOwner->email) { + continue; + } + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $user->email, + 'client_address' => $this->clientAddress + ] + ); + + $this->assertFalse($request->shouldDefer()); + } + } + + public function testMultipleUsersAnyEnabled() + { + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $this->domainOwner->email, + 'client_address' => $this->clientAddress + ] + ); + + foreach ($this->domainUsers as $user) { + \App\Greylist\Connect::create( + [ + 'sender_local' => 'someone', + 'sender_domain' => 'sender.domain', + 'recipient_hash' => hash('sha256', $user->email), + 'recipient_id' => $user->id, + 'recipient_type' => \App\User::class, + 'connect_count' => 1, + 'net_id' => $this->net->id, + 'net_type' => \App\IP4Net::class + ] + ); + + \App\Greylist\Setting::create( + [ + 'object_id' => $user->id, + 'object_type' => \App\User::class, + 'key' => 'greylist_enabled', + 'value' => ($user->id == $this->jack->id) ? 'true' : 'false' + ] + ); + + if ($user->email == $this->domainOwner->email) { + continue; + } + + $request = new \App\Greylist\Request( + [ + 'sender' => 'someone@sender.domain', + 'recipient' => $user->email, + 'client_address' => $this->clientAddress + ] + ); + + if ($user->id == $this->jack->id) { + $this->assertTrue($request->shouldDefer()); + } else { + $this->assertFalse($request->shouldDefer()); + } + } + } + + private function generateInstanceId() + { + $instance = []; + + for ($x = 0; $x < 3; $x++) { + for ($y = 0; $y < 3; $y++) { + $instance[] .= substr('01234567889', rand(0, 9), 1); + } + } + + return implode('.', $instance); + } +} diff --git a/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php b/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php new file mode 100644 index 00000000..aed85bd6 --- /dev/null +++ b/src/tests/Feature/Stories/SenderPolicyFrameworkTest.php @@ -0,0 +1,306 @@ + 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@spf-fail.kolab.org', + 'client_name' => 'mx.kolabnow.com', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + } + + public function testSenderFailv6() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@spf-fail.kolab.org', + 'client_name' => 'mx.kolabnow.com', + // actually IN AAAA gmail.com. + 'client_address' => '2a00:1450:400a:801::2005', + 'recipient' => $this->domainOwner->email + ]; + + $this->assertFalse(strpos(':', $data['client_address'])); + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + } + + public function testSenderNone() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@spf-none.kolab.org', + 'client_name' => 'mx.kolabnow.com', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(200); + } + + public function testSenderNoNet() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@spf-none.kolab.org', + 'client_name' => 'mx.kolabnow.com', + 'client_address' => '256.0.0.1', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + } + + public function testSenderPass() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@spf-pass.kolab.org', + 'client_name' => 'mx.kolabnow.com', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(200); + } + + public function testSenderPassAll() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@spf-passall.kolab.org', + 'client_name' => 'mx.kolabnow.com', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(200); + } + + public function testSenderPermerror() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@spf-permerror.kolab.org', + 'client_name' => 'mx.kolabnow.com', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + } + + public function testSenderSoftfail() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@spf-fail.kolab.org', + 'client_name' => 'mx.kolabnow.com', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(200); + } + + public function testSenderTemperror() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@spf-temperror.kolab.org', + 'client_name' => 'mx.kolabnow.com', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + } + + public function testSenderRelayPolicyHeloExactNegative() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@amazon.co.uk', + 'client_name' => 'helo.some.relayservice.domain', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + + $this->domainOwner->setSetting('spf_whitelist', json_encode(['the.only.acceptable.helo'])); + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + + $this->domainOwner->removeSetting('spf_whitelist'); + } + + public function testSenderRelayPolicyHeloExactPositive() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@amazon.co.uk', + 'client_name' => 'helo.some.relayservice.domain', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + + $this->domainOwner->setSetting('spf_whitelist', json_encode(['helo.some.relayservice.domain'])); + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(200); + + $this->domainOwner->removeSetting('spf_whitelist'); + } + + + public function testSenderRelayPolicyRegexpNegative() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@amazon.co.uk', + 'client_name' => 'helo.some.relayservice.domain', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + + $this->domainOwner->setSetting('spf_whitelist', json_encode(['/a\.domain/'])); + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + + $this->domainOwner->removeSetting('spf_whitelist'); + } + + public function testSenderRelayPolicyRegexpPositive() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@amazon.co.uk', + 'client_name' => 'helo.some.relayservice.domain', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + + $this->domainOwner->setSetting('spf_whitelist', json_encode(['/relayservice\.domain/'])); + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(200); + + $this->domainOwner->removeSetting('spf_whitelist'); + } + + public function testSenderRelayPolicyWildcardSubdomainNegative() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@amazon.co.uk', + 'client_name' => 'helo.some.relayservice.domain', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + + $this->domainOwner->setSetting('spf_whitelist', json_encode(['.helo.some.relayservice.domain'])); + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + + $this->domainOwner->removeSetting('spf_whitelist'); + } + + public function testSenderRelayPolicyWildcardSubdomainPositive() + { + $data = [ + 'instance' => 'test.local.instance', + 'protocol_state' => 'RCPT', + 'sender' => 'sender@amazon.co.uk', + 'client_name' => 'helo.some.relayservice.domain', + 'client_address' => '212.103.80.148', + 'recipient' => $this->domainOwner->email + ]; + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(403); + + $this->domainOwner->setSetting('spf_whitelist', json_encode(['.some.relayservice.domain'])); + + $response = $this->post('/api/webhooks/spf', $data); + + $response->assertStatus(200); + + $this->domainOwner->removeSetting('spf_whitelist'); + } +} diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index e9f933c6..4d8817d9 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -1,70 +1,47 @@ withoutMiddleware(ThrottleRequests::class); } - protected function backdateEntitlements($entitlements, $targetDate) - { - $wallets = []; - $ids = []; - - foreach ($entitlements as $entitlement) { - $ids[] = $entitlement->id; - $wallets[] = $entitlement->wallet_id; - } - - \App\Entitlement::whereIn('id', $ids)->update([ - 'created_at' => $targetDate, - 'updated_at' => $targetDate, - ]); - - if (!empty($wallets)) { - $wallets = array_unique($wallets); - $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); - - \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]); - } - } - /** * Set baseURL to the admin UI location */ protected static function useAdminUrl(): void { // This will set base URL for all tests in a file. // If we wanted to access both user and admin in one test // we can also just call post/get/whatever with full url \config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]); url()->forceRootUrl(config('app.url')); } /** * Set baseURL to the reseller UI location */ protected static function useResellerUrl(): void { // This will set base URL for all tests in a file. // If we wanted to access both user and admin in one test // we can also just call post/get/whatever with full url \config(['app.url' => str_replace('//', '//reseller.', \config('app.url'))]); url()->forceRootUrl(config('app.url')); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index d88cbe72..2f4776a2 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,248 +1,459 @@ 'John', + 'last_name' => 'Doe', + 'organization' => 'Test Domain Owner', + ]; + + /** + * Some users for the hosted domain, ultimately including the owner. + * + * @var \App\User[] + */ + protected $domainUsers = []; + + /** + * A specific user that is a regular user in the hosted domain. + */ + protected $jack; + + /** + * A specific user that is a controller on the wallet to which the hosted domain is charged. + */ + protected $jane; + + /** + * A specific user that has a second factor configured. + */ + protected $joe; + + /** + * One of the domains that is available for public registration. + * + * @var \App\Domain + */ + protected $publicDomain; + + /** + * A newly generated user in a public domain. + * + * @var \App\User + */ + protected $publicDomainUser; + + /** + * A placeholder for a password that can be generated. + * + * Should be generated with `\App\Utils::generatePassphrase()`. + * + * @var string + */ + protected $userPassword; + + /** + * Assert that the entitlements for the user match the expected list of entitlements. + * + * @param \App\User $user The user for which the entitlements need to be pulled. + * @param array $expected An array of expected \App\SKU titles. */ protected function assertUserEntitlements($user, $expected) { // Assert the user entitlements $skus = $user->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } + protected function backdateEntitlements($entitlements, $targetDate) + { + $wallets = []; + $ids = []; + + foreach ($entitlements as $entitlement) { + $ids[] = $entitlement->id; + $wallets[] = $entitlement->wallet_id; + } + + \App\Entitlement::whereIn('id', $ids)->update([ + 'created_at' => $targetDate, + 'updated_at' => $targetDate, + ]); + + if (!empty($wallets)) { + $wallets = array_unique($wallets); + $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); + + \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]); + } + } + /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', 'App\Handlers\Distlist', ]; $betas = \App\Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; + foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } - $transaction = Transaction::create([ + $transaction = Transaction::create( + [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', - ]); + ] + ); + $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); - $transaction = Transaction::create([ + $transaction = Transaction::create( + [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', - ]); + ] + ); + $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); + $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); + $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); + $result[] = $transaction; } return $result; } + /** + * Delete a test domain whatever it takes. + * + * @coversNothing + */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } + /** + * Delete a test group whatever it takes. + * + * @coversNothing + */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } $job = new \App\Jobs\Group\DeleteJob($group->id); $job->handle(); $group->forceDelete(); } + /** + * Delete a test user whatever it takes. + * + * @coversNothing + */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\User\DeleteJob($user->id); $job->handle(); $user->forceDelete(); } + /** + * Helper to access protected property of an object + */ + protected static function getObjectProperty($object, $property_name) + { + $reflection = new \ReflectionClass($object); + $property = $reflection->getProperty($property_name); + $property->setAccessible(true); + + return $property->getValue($object); + } + /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. + * + * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get User object by email, create it if needed. * Skip LDAP jobs. + * + * @coversNothing */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } - /** - * Helper to access protected property of an object - */ - protected static function getObjectProperty($object, $property_name) - { - $reflection = new \ReflectionClass($object); - $property = $reflection->getProperty($property_name); - $property->setAccessible(true); - - return $property->getValue($object); - } - /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } + + public function setUp(): void + { + parent::setUp(); + + $this->userPassword = \App\Utils::generatePassphrase(); + + $this->domainHosted = $this->getTestDomain( + 'test.domain', + [ + 'type' => \App\Domain::TYPE_EXTERNAL, + 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED + ] + ); + + $packageKolab = \App\Package::where('title', 'kolab')->first(); + + $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); + $this->domainOwner->assignPackage($packageKolab); + $this->domainOwner->setSettings($this->domainOwnerSettings); + + // separate for regular user + $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); + + // separate for wallet controller + $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); + + $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); + + $this->domainUsers[] = $this->jack; + $this->domainUsers[] = $this->jane; + $this->domainUsers[] = $this->joe; + $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); + + foreach ($this->domainUsers as $user) { + $this->domainOwner->assignPackage($packageKolab, $user); + } + + $this->domainUsers[] = $this->domainOwner; + + // assign second factor to joe + $this->joe->assignSku(\App\Sku::where('title', '2fa')->first()); + \App\Auth\SecondFactor::seed($this->joe->email); + + usort( + $this->domainUsers, + function ($a, $b) { + return $a->email > $b->email; + } + ); + + $this->domainHosted->assignPackage( + \App\Package::where('title', 'domain-hosting')->first(), + $this->domainOwner + ); + + $wallet = $this->domainOwner->wallets()->first(); + + $wallet->addController($this->jane); + + $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); + $this->publicDomainUser = $this->getTestUser( + 'john@' . $this->publicDomain->namespace, + ['password' => $this->userPassword] + ); + + $this->publicDomainUser->assignPackage($packageKolab); + } + + public function tearDown(): void + { + foreach ($this->domainUsers as $user) { + if ($user == $this->domainOwner) { + continue; + } + + $this->deleteTestUser($user->email); + } + + $this->deleteTestUser($this->domainOwner->email); + $this->deleteTestDomain($this->domainHosted->namespace); + + $this->deleteTestUser($this->publicDomainUser->email); + + parent::tearDown(); + } }