diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php new file mode 100644 index 00000000..b9938f3b --- /dev/null +++ b/src/app/AuthAttempt.php @@ -0,0 +1,183 @@ + 'datetime', + 'last_seen' => 'datetime' + ]; + + /** + * Prepare a date for array / JSON serialization. + * + * Required to not omit timezone and match the format of update_at/created_at timestamps. + * + * @param \DateTimeInterface $date + * @return string + */ + protected function serializeDate(\DateTimeInterface $date) + { + return Carbon::instance($date)->toIso8601ZuluString('microseconds'); + } + + /** + * Returns true if the authentication attempt is accepted. + * + * @return bool + */ + public function isAccepted() + { + if ($this->status == 'ACCEPTED' && Carbon::now() < $this->expires_at) { + return true; + } + return false; + } + + /** + * Returns true if the authentication attempt is denied. + * + * @return bool + */ + public function isDenied() + { + return ($this->status == 'DENIED'); + } + + /** + * Accept the authentication attempt. + */ + public function accept() + { + $this->expires_at = Carbon::now()->addHours(8); + $this->status = "ACCEPTED"; + $this->reason = ''; + } + + /** + * Deny the authentication attempt. + */ + public function deny() + { + $this->status = "DENIED"; + $this->reason = ''; + } + + /** + * Notify the user of this authentication attempt. + * + * @return bool false if there was no means to notify + */ + public function notify() + { + return \App\CompanionApp::notifyUser($this->user_id, ['token' => $this->id]); + } + + /** + * Notify the user and wait for a confirmation. + */ + private function notifyAndWait() + { + if (!$this->notify()) { + //FIXME if the webclient can confirm too we don't need to abort here. + \Log::warning("There is no 2fa device to notify."); + return false; + } + + \Log::debug("Authentication attempt: {$this->id}"); + + $confirmationTimeout = 120; + $timeout = Carbon::now()->addSeconds($confirmationTimeout); + + $authAttempt = $this; + do { + if ($authAttempt->isDenied()) { + \Log::debug("The authentication attempt was denied {$authAttempt->id}"); + return false; + } + + if ($authAttempt->isAccepted()) { + \Log::debug("The authentication attempt was accepted {$authAttempt->id}"); + return true; + } + + if ($timeout < Carbon::now()) { + \Log::debug("The authentication attempt timed-out: {$authAttempt->id}"); + return false; + } + + sleep(2); + $authAttempt = $authAttempt->fresh(); + } while (true); + } + + /** + * Record an authentication attempt + */ + public static function recordAuthAttempt(\App\User $user, $clientIP) + { + $authAttempt = \App\AuthAttempt::where('ip', $clientIP)->where('user_id', $user->id)->first(); + + if (!$authAttempt) { + $authAttempt = new \App\AuthAttempt(); + $authAttempt->ip = $clientIP; + $authAttempt->user_id = $user->id; + } + + $authAttempt->last_seen = Carbon::now(); + $authAttempt->save(); + + return $authAttempt; + } + + /** + * Trigger a notification if necessary and wait for confirmation. + * + * @return bool + */ + public function waitFor2FA() + { + if ($this->isAccepted()) { + return true; + } + if ($this->isDenied()) { + return false; + } + + if (!$this->notifyAndWait()) { + return false; + } + + // Ensure the authAttempt is now accepted + $freshAttempt = $this->fresh(); + return $freshAttempt->isAccepted(); + } +} diff --git a/src/app/CompanionApp.php b/src/app/CompanionApp.php new file mode 100644 index 00000000..04dae164 --- /dev/null +++ b/src/app/CompanionApp.php @@ -0,0 +1,82 @@ + $deviceIds, + 'data' => $data + ]; + + $headers = array( + 'Content-Type:application/json', + "Authorization:key={$apiKey}" + ); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($fields)); + $result = curl_exec($ch); + if ($result === false) { + throw new \Exception('FCM Send Error: ' . curl_error($ch)); + } + curl_close($ch); + return $result; + } + + /** + * Send a notification to a user. + * + * @return bool true if a notification has been sent + */ + public static function notifyUser($userId, $data) + { + $notificationTokens = \App\CompanionApp::where('user_id', $userId) + ->where('mfa_enabled', true) + ->get() + ->map(function ($app) { + return $app->notification_token; + }) + ->all(); + + if (empty($notificationTokens)) { + \Log::debug("There is no 2fa device to notify."); + return false; + } + + self::pushFirebaseNotification($notificationTokens, $data); + return true; + } +} diff --git a/src/app/Console/Commands/AuthAttemptDelete.php b/src/app/Console/Commands/AuthAttemptDelete.php new file mode 100644 index 00000000..3d559a83 --- /dev/null +++ b/src/app/Console/Commands/AuthAttemptDelete.php @@ -0,0 +1,39 @@ +argument('id')); + + if ($authAttempt == null) { + return 1; + } + + $authAttempt->delete(); + } +} diff --git a/src/app/Console/Commands/AuthAttemptList.php b/src/app/Console/Commands/AuthAttemptList.php new file mode 100644 index 00000000..bdf34010 --- /dev/null +++ b/src/app/Console/Commands/AuthAttemptList.php @@ -0,0 +1,40 @@ +each( + function ($authAttempt) { + $msg = var_export($authAttempt->toArray(), true); + $this->info($msg); + } + ); + } +} diff --git a/src/app/Console/Commands/AuthAttemptPurge.php b/src/app/Console/Commands/AuthAttemptPurge.php new file mode 100644 index 00000000..0602260f --- /dev/null +++ b/src/app/Console/Commands/AuthAttemptPurge.php @@ -0,0 +1,36 @@ +subDays(30); + \App\AuthAttempt::where('updated_at', '<', $cutoff) + ->delete(); + } +} diff --git a/src/app/Http/Controllers/API/NGINXController.php b/src/app/Http/Controllers/API/NGINXController.php new file mode 100644 index 00000000..0a51c9ef --- /dev/null +++ b/src/app/Http/Controllers/API/NGINXController.php @@ -0,0 +1,212 @@ + + * I suppose that's not necessary given that we have the information avialable in the headers? + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\Response The response + */ + public function authenticate(Request $request) + { + /** + * Auth-Login-Attempt: 1 + * Auth-Method: plain + * Auth-Pass: simple123 + * Auth-Protocol: imap + * Auth-Ssl: on + * Auth-User: john@kolab.org + * Client-Ip: 127.0.0.1 + * Host: 127.0.0.1 + * + * Auth-SSL: on + * Auth-SSL-Verify: SUCCESS + * Auth-SSL-Subject: /CN=example.com + * Auth-SSL-Issuer: /CN=example.com + * Auth-SSL-Serial: C07AD56B846B5BFF + * Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad + */ + + \Log::info("Authentication attempt"); + \Log::debug($request->headers); + + $login = $request->headers->get('Auth-User', null); + + if (empty($login)) { + return $this->byebye($request, "Empty login"); + } + + // validate user exists, otherwise bye bye + $user = \App\User::where('email', $login)->first(); + + if (!$user) { + return $this->byebye($request, "User not found"); + } + + // TODO: validate the user's domain is A-OK (active, confirmed, not suspended, ldapready) + // TODO: validate the user is A-OK (active, not suspended, ldapready, imapready) + + // validate password, otherwise bye bye + $password = $request->headers->get('Auth-Pass', null); + + if (empty($password)) { + return $this->byebye($request, "Empty password"); + } + + $result = Hash::check($password, $user->password); + + $clientIP = $request->headers->get('Client-Ip', null); + + if (!$result) { + $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); + if (!$attempt->isAccepted()) { + $attempt->reason = \App\AuthAttempt::REASON_PASSWORD; + $attempt->save(); + $attempt->notify(); + } + \Log::info("Failed authentication attempt due to password mismatch for user: {$login}"); + return $this->byebye($request, "Password mismatch"); + } + + // validate country of origin against restrictions, otherwise bye bye + /* $countryCodes = json_decode($user->getSetting('limit_geo', "[]")); */ + + /* \Log::debug("Countries for {$user->email}: " . var_export($countryCodes, true)); */ + + /* // TODO: Consider "new geographical area notification". */ + + /* if (!empty($countryCodes)) { */ + /* // fake the country is NL, and the limitation is CH */ + /* if ($clientIP == '127.0.0.1' && $login == "piet@kolab.org") { */ + /* $country = "NL"; */ + /* } else { */ + /* // TODO: GeoIP reliance */ + /* $country = "CH"; */ + /* } */ + + /* if (!in_array($country, $countryCodes)) { */ + /* // TODO: Log, notify user. */ + /* return $this->byebye($request, "Country code mismatch"); */ + /* } */ + /* } */ + + // TODO: Apply some sort of limit for Auth-Login-Attempt -- docs say it is the number of + // attempts over the same authAttempt. + + // Check 2fa + if ($user->getSetting('2fa_enabled', false)) { + $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); + if (!$authAttempt->waitFor2FA()) { + return $this->byebye($request, "2fa failed"); + } + } + + // All checks passed + switch ($request->headers->get('Auth-Protocol')) { + case "imap": + return $this->authenticateIMAP($request, $user->getSetting('guam_enabled', false), $password); + case "smtp": + return $this->authenticateSMTP($request, $password); + default: + return $this->byebye($request, "unknown protocol in request"); + } + } + + /** + * Create an imap authentication response. + * + * @param \Illuminate\Http\Request $request The API request. + * @param bool $prefGuam Wether or not guam is enabled. + * @param string $password The password to include in the response. + * + * @return \Illuminate\Http\Response The response + */ + private function authenticateIMAP(Request $request, $prefGuam, $password) + { + if ($prefGuam) { + if ($request->headers->get('Auth-Ssl') == 'on') { + $port = \config('imap.guam_tls_port'); + } else { + $port = \config('imap.guam_port'); + } + } else { + if ($request->headers->get('Auth-Ssl') == 'on') { + $port = \config('imap.tls_port'); + } else { + $port = \config('imap.port'); + } + } + + $response = response("")->withHeaders( + [ + "Auth-Status" => "OK", + "Auth-Server" => \config('imap.host'), + "Auth-Port" => $port, + "Auth-Pass" => $password + ] + ); + + \Log::debug("Response with headers:\n{$response->headers}"); + + return $response; + } + + /** + * Create an smtp authentication response. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $password The password to include in the response. + * + * @return \Illuminate\Http\Response The response + */ + private function authenticateSMTP(Request $request, $password) + { + $response = response("")->withHeaders( + [ + "Auth-Status" => "OK", + "Auth-Server" => \config('smtp.host'), + "Auth-Port" => \config('smtp.port'), + "Auth-Pass" => $password + ] + ); + + \Log::debug("Response with headers:\n{$response->headers}"); + + return $response; + } + + /** + * Create a failed-authentication response. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $reason The reason for the failure. + * + * @return \Illuminate\Http\Response The response + */ + private function byebye(Request $request, $reason = null) + { + \Log::debug("Byebye: {$reason}"); + $response = response("")->withHeaders( + [ + "Auth-Status" => "NO", + "Auth-Wait" => 3 + ] + ); + + \Log::debug("Response with headers:\n{$response->headers}"); + + return $response; + } +} diff --git a/src/app/Http/Controllers/API/V4/AuthAttemptsController.php b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php new file mode 100644 index 00000000..22a172cd --- /dev/null +++ b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php @@ -0,0 +1,97 @@ +user(); + if ($user->id != $authAttempt->user_id) { + return $this->errorResponse(403); + } + + \Log::debug("Confirm on {$authAttempt->id}"); + $authAttempt->accept(); + $authAttempt->save(); + return response("", 200); + } + + public function deny($id) + { + $authAttempt = AuthAttempt::findOrFail($id); + + $user = Auth::guard()->user(); + if ($user->id != $authAttempt->user_id) { + return $this->errorResponse(403); + } + + \Log::debug("Deny on {$authAttempt->id}"); + $authAttempt->deny(); + $authAttempt->save(); + return response("", 200); + } + + public function details($id) + { + $authAttempt = AuthAttempt::findOrFail($id); + $user = Auth::guard()->user(); + + \Log::debug("Getting details {$authAttempt->user_id} {$user->id}"); + if ($user->id != $authAttempt->user_id) { + return $this->errorResponse(403); + } + + \Log::debug("Details on {$authAttempt->id}"); + return response()->json([ + 'status' => 'success', + 'username' => $user->email, + 'ip' => $authAttempt->ip, + 'timestamp' => $authAttempt->updated_at, + 'country' => \App\Utils::countryForIP($authAttempt->ip), + 'entry' => $authAttempt->toArray() + ]); + } + + /** + * Listing of client authAttempts. + * + * All authAttempt attempts from the current user + * + * @return \Illuminate\Http\JsonResponse + */ + public function index(Request $request) + { + $user = Auth::guard()->user(); + + $pageSize = 10; + $page = intval($request->input('page')) ?: 1; + $hasMore = false; + + $result = \App\AuthAttempt::where('user_id', $user->id) + ->orderBy('updated_at', 'desc') + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + $result = $result->map(function ($authAttempt) { + return $authAttempt->toArray(); + }); + + return response()->json($result); + } +} diff --git a/src/app/Http/Controllers/API/V4/CompanionAppsController.php b/src/app/Http/Controllers/API/V4/CompanionAppsController.php new file mode 100644 index 00000000..5d53d808 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php @@ -0,0 +1,43 @@ +user(); + if (!$user) { + throw new \Exception("Authentication required."); + } + $notificationToken = $request->notificationToken; + $deviceId = $request->deviceId; + + \Log::info("Registering app. Notification token: {$notificationToken} Device id: {$deviceId}"); + + $app = \App\CompanionApp::where('device_id', $deviceId)->first(); + if (!$app) { + $app = new \App\CompanionApp(); + $app->user_id = $user->id; + $app->device_id = $deviceId; + $app->mfa_enabled = true; + } + + $app->notification_token = $notificationToken; + $app->save(); + + $result['status'] = 'success'; + return response()->json($result); + } +} diff --git a/src/config/firebase.php b/src/config/firebase.php new file mode 100644 index 00000000..e856f8e1 --- /dev/null +++ b/src/config/firebase.php @@ -0,0 +1,6 @@ + Project Settings -> CLOUD MESSAGING -> Server key*/ + 'api_key' => env('FIREBASE_API_KEY'), + 'api_url' => env('FIREBASE_API_URL', 'https://fcm.googleapis.com/fcm/send'), + ]; diff --git a/src/config/imap.php b/src/config/imap.php index bdd2b394..11bb7bcf 100644 --- a/src/config/imap.php +++ b/src/config/imap.php @@ -1,9 +1,14 @@ env('IMAP_URI', '127.0.0.1'), 'admin_login' => env('IMAP_ADMIN_LOGIN', 'cyrus-admin'), 'admin_password' => env('IMAP_ADMIN_PASSWORD', null), 'verify_peer' => env('IMAP_VERIFY_PEER', true), - 'verify_host' => env('IMAP_VERIFY_HOST', true) + 'verify_host' => env('IMAP_VERIFY_HOST', true), + 'host' => env('IMAP_HOST', '127.0.0.1'), + 'guam_tls_port' => env('IMAP_GUAM_TLS_PORT', 9993), + 'guam_port' => env('IMAP_GUAM_PORT', 9143), + 'tls_port' => env('IMAP_TLS_PORT', 11993), + 'port' => env('IMAP_PORT', 12143), ]; diff --git a/src/config/smtp.php b/src/config/smtp.php new file mode 100644 index 00000000..9c0d98b0 --- /dev/null +++ b/src/config/smtp.php @@ -0,0 +1,6 @@ + env('SMTP_HOST', '127.0.0.1'), + 'port' => env('SMTP_PORT', 10465), +]; diff --git a/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php b/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php new file mode 100644 index 00000000..f76420fb --- /dev/null +++ b/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php @@ -0,0 +1,45 @@ +bigIncrements('id'); + $table->bigInteger('user_id')->index(); + $table->string('ip', 36); + $table->string('status', 36)->default('NEW'); + $table->string('reason', 36)->nullable(); + $table->datetime('expires_at')->nullable(); + $table->datetime('last_seen')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'ip']); + + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('auth_attempts'); + } +} diff --git a/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php b/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php new file mode 100644 index 00000000..2b9973bb --- /dev/null +++ b/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php @@ -0,0 +1,42 @@ +bigIncrements('id'); + $table->bigInteger('user_id')->index(); + $table->string('notification_token')->nullable(); + $table->string('device_id', 100); + $table->string('name')->nullable(); + $table->boolean('mfa_enabled'); + $table->timestamps(); + + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('companion_apps'); + } +} diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php index 8aace6ff..51259b0a 100644 --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -1,202 +1,205 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'email' => 'john@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'organization' => 'Kolab Developers', 'phone' => '+1 509-248-1111', + // 'limit_geo' => json_encode(["CH"]), + 'guam_enabled' => false, + '2fa_enabled' => true ] ); $john->setAliases(['john.doe@kolab.org']); $wallet = $john->wallets->first(); $packageDomain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $packageLite = \App\Package::withEnvTenantContext()->where('title', 'lite')->first(); $domain->assignPackage($packageDomain, $john); $john->assignPackage($packageKolab); $jack = User::create( [ 'email' => 'jack@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); $jack->setAliases(['jack.daniels@kolab.org']); $john->assignPackage($packageKolab, $jack); foreach ($john->entitlements as $entitlement) { $entitlement->created_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->updated_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->save(); } $ned = User::create( [ 'email' => 'ned@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $ned->setSettings( [ 'first_name' => 'Edward', 'last_name' => 'Flanders', 'currency' => 'USD', 'country' => 'US' ] ); $john->assignPackage($packageKolab, $ned); $ned->assignSku(\App\Sku::withEnvTenantContext()->where('title', 'activesync')->first(), 1); // Ned is a controller on Jack's wallet $john->wallets()->first()->addController($ned); // Ned is also our 2FA test user $sku2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $ned->assignSku($sku2fa); try { SecondFactor::seed('ned@kolab.org'); } catch (\Exception $e) { // meh } $joe = User::create( [ 'email' => 'joe@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $john->assignPackage($packageLite, $joe); //$john->assignSku(Sku::firstOrCreate(['title' => 'beta'])); //$john->assignSku(Sku::firstOrCreate(['title' => 'meet'])); $joe->setAliases(['joe.monster@kolab.org']); $jeroen = User::create( [ 'email' => 'jeroen@jeroen.jeroen', 'password' => \App\Utils::generatePassphrase() ] ); $jeroen->role = 'admin'; $jeroen->save(); $reseller = User::create( [ 'email' => 'reseller@' . \config('app.domain'), 'password' => \App\Utils::generatePassphrase() ] ); $reseller->role = 'reseller'; $reseller->save(); $reseller->assignPackage($packageKolab); // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); foreach ($tenants as $tenant) { $domain = Domain::where('tenant_id', $tenant->id)->first(); $packageKolab = \App\Package::where( [ 'title' => 'kolab', 'tenant_id' => $tenant->id ] )->first(); if ($domain) { $reseller = User::create( [ 'email' => 'reseller@' . $domain->namespace, 'password' => \App\Utils::generatePassphrase() ] ); $reseller->role = 'reseller'; $reseller->tenant_id = $tenant->id; $reseller->save(); $reseller->assignPackage($packageKolab); $user = User::create( [ 'email' => 'user@' . $domain->namespace, 'password' => \App\Utils::generatePassphrase() ] ); $user->tenant_id = $tenant->id; $user->save(); $user->assignPackage($packageKolab); } } } } diff --git a/src/routes/api.php b/src/routes/api.php index 279fd894..234a719a 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,236 +1,246 @@ '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('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.website_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.website_domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { + Route::post('companion/register', 'API\V4\CompanionAppsController@register'); + + Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); + Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); + Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); + Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); + 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::post('domains/{id}/config', 'API\V4\DomainsController@setConfig'); 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::post('users/{id}/config', 'API\V4\UsersController@setConfig'); 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.website_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.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); + Route::get('nginx', 'API\NGINXController@authenticate'); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.domain'), 'prefix' => $prefix . 'api/webhooks/policy' ], function () { Route::post('greylist', 'API\V4\PolicyController@greylist'); Route::post('ratelimit', 'API\V4\PolicyController@ratelimit'); Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework'); } ); } if (\config('app.with_admin')) { 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('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); 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::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); } if (\config('app.with_reseller')) { 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::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::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); 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::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); } diff --git a/src/tests/Feature/Controller/AuthAttemptsTest.php b/src/tests/Feature/Controller/AuthAttemptsTest.php new file mode 100644 index 00000000..5f6f82cd --- /dev/null +++ b/src/tests/Feature/Controller/AuthAttemptsTest.php @@ -0,0 +1,103 @@ +deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestDomain('userscontroller.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestDomain('userscontroller.com'); + + parent::tearDown(); + } + + public function testRecord(): void + { + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + $this->assertEquals($authAttempt->user_id, $user->id); + $this->assertEquals($authAttempt->ip, "10.0.0.1"); + $authAttempt->refresh(); + $this->assertEquals($authAttempt->status, "NEW"); + + $authAttempt2 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + $this->assertEquals($authAttempt->id, $authAttempt2->id); + + $authAttempt3 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.2"); + $this->assertNotEquals($authAttempt->id, $authAttempt3->id); + } + + + public function testAcceptDeny(): void + { + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + + $response = $this->actingAs($user)->post("api/v4/auth-attempts/{$authAttempt->id}/confirm"); + $response->assertStatus(200); + $authAttempt->refresh(); + $this->assertTrue($authAttempt->isAccepted()); + + $response = $this->actingAs($user)->post("api/v4/auth-attempts/{$authAttempt->id}/deny"); + $response->assertStatus(200); + $authAttempt->refresh(); + $this->assertTrue($authAttempt->isDenied()); + } + + + public function testDetails(): void + { + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + + $response = $this->actingAs($user)->get("api/v4/auth-attempts/{$authAttempt->id}/details"); + $response->assertStatus(200); + + $json = $response->json(); + + $authAttempt->refresh(); + + $this->assertEquals($user->email, $json['username']); + $this->assertEquals($authAttempt->ip, $json['ip']); + $this->assertEquals(json_encode($authAttempt->updated_at), "\"" . $json['timestamp'] . "\""); + $this->assertEquals("CH", $json['country']); + } + + + public function testList(): void + { + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + $authAttempt2 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.2"); + + $response = $this->actingAs($user)->get("api/v4/auth-attempts"); + $response->assertStatus(200); + + $json = $response->json(); + + /* var_export($json); */ + + $this->assertEquals(count($json), 2); + $this->assertEquals($json[0]['id'], $authAttempt->id); + $this->assertEquals($json[1]['id'], $authAttempt2->id); + } +} diff --git a/src/tests/Feature/Controller/CompanionApps.php b/src/tests/Feature/Controller/CompanionApps.php new file mode 100644 index 00000000..d4e7aabc --- /dev/null +++ b/src/tests/Feature/Controller/CompanionApps.php @@ -0,0 +1,67 @@ +deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestDomain('userscontroller.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestDomain('userscontroller.com'); + + parent::tearDown(); + } + + /** + * Test registering the app + */ + public function testRegister(): void + { + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + + $notificationToken = "notificationToken"; + $deviceId = "deviceId"; + + $response = $this->actingAs($user)->post( + "api/v4/companion/register", + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ); + + $response->assertStatus(200); + + $companionApp = \App\CompanionApp::where('device_id', $deviceId)->first(); + $this->assertTrue($companionApp != null); + $this->assertEquals($deviceId, $companionApp->device_id); + $this->assertEquals($notificationToken, $companionApp->notification_token); + + // Test a token update + $notificationToken = "notificationToken2"; + $response = $this->actingAs($user)->post( + "api/v4/companion/register", + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ); + + $response->assertStatus(200); + + $companionApp->refresh(); + $this->assertEquals($notificationToken, $companionApp->notification_token); + } +} diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php new file mode 100644 index 00000000..02f8f382 --- /dev/null +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -0,0 +1,119 @@ +getTestUser('john@kolab.org'); + \App\CompanionApp::where('user_id', $john->id)->delete(); + \App\AuthAttempt::where('user_id', $john->id)->delete(); + $john->setSettings( + [ + // 'limit_geo' => json_encode(["CH"]), + 'guam_enabled' => false, + '2fa_enabled' => false + ] + ); + } + + public function tearDown(): void + { + + $john = $this->getTestUser('john@kolab.org'); + \App\CompanionApp::where('user_id', $john->id)->delete(); + \App\AuthAttempt::where('user_id', $john->id)->delete(); + $john->setSettings( + [ + // 'limit_geo' => json_encode(["CH"]), + 'guam_enabled' => false, + '2fa_enabled' => false + ] + ); + parent::tearDown(); + } + + /** + * Test the webhook + */ + public function testNGINXWebhook(): void + { + $john = $this->getTestUser('john@kolab.org'); + + $response = $this->actingAs($john)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'NO'); + + $headers = [ + 'Auth-Login-Attempt' => '1', + 'Auth-Method' => 'plain', + 'Auth-Pass' => 'simple123', + 'Auth-Protocol' => 'imap', + 'Auth-Ssl' => 'on', + 'Auth-User' => 'john@kolab.org', + 'Client-Ip' => '127.0.0.1', + 'Host' => '127.0.0.1', + 'Auth-SSL' => 'on', + 'Auth-SSL-Verify' => 'SUCCESS', + 'Auth-SSL-Subject' => '/CN=example.com', + 'Auth-SSL-Issuer' => '/CN=example.com', + 'Auth-SSL-Serial' => 'C07AD56B846B5BFF', + 'Auth-SSL-Fingerprint' => '29d6a80a123d13355ed16b4b04605e29cb55a5ad' + ]; + + // Pass + $response = $this->actingAs($john)->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + $response->assertHeader('auth-port', '11993'); + + // Invalid Password + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-Pass'] = "Invalid"; + $response = $this->actingAs($john)->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'NO'); + + + // Guam + $john->setSettings( + [ + 'guam_enabled' => true, + ] + ); + + $response = $this->actingAs($john)->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + $response->assertHeader('auth-port', '9993'); + + // 2-FA without device + $john->setSettings( + [ + '2fa_enabled' => true, + ] + ); + \App\CompanionApp::where('user_id', $john->id)->delete(); + + $response = $this->actingAs($john)->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'NO'); + + // 2-FA with accepted auth attempt + $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); + $authAttempt->accept(); + $authAttempt->save(); + + $response = $this->actingAs($john)->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + } +}