diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php new file mode 100644 --- /dev/null +++ b/src/app/AuthAttempt.php @@ -0,0 +1,194 @@ + '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): string + { + return Carbon::instance($date)->toIso8601ZuluString('microseconds'); + } + + /** + * Returns true if the authentication attempt is accepted. + * + * @return bool + */ + public function isAccepted(): bool + { + if ($this->status == self::STATUS_ACCEPTED && Carbon::now() < $this->expires_at) { + return true; + } + return false; + } + + /** + * Returns true if the authentication attempt is denied. + * + * @return bool + */ + public function isDenied(): bool + { + return ($this->status == self::STATUS_DENIED); + } + + /** + * Accept the authentication attempt. + */ + public function accept() + { + $this->expires_at = Carbon::now()->addHours(8); + $this->status = self::STATUS_ACCEPTED; + $this->reason = ''; + $this->save(); + } + + /** + * Deny the authentication attempt. + */ + public function deny() + { + $this->status = self::STATUS_DENIED; + $this->reason = ''; + $this->save(); + } + + /** + * Notify the user of this authentication attempt. + * + * @return bool false if there was no means to notify + */ + public function notify(): bool + { + 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); + + do { + if ($this->isDenied()) { + \Log::debug("The authentication attempt was denied {$this->id}"); + return false; + } + + if ($this->isAccepted()) { + \Log::debug("The authentication attempt was accepted {$this->id}"); + return true; + } + + if ($timeout < Carbon::now()) { + \Log::debug("The authentication attempt timed-out: {$this->id}"); + return false; + } + + sleep(2); + $this->refresh(); + } while (true); + } + + /** + * Record a new authentication attempt or update an existing one. + * + * @param \App\User $user The user attempting to authenticate. + * @param string $clientIp The ip the authentication attempt is coming from. + * + * @return \App\AuthAttempt + */ + 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(): bool + { + if ($this->isAccepted()) { + return true; + } + if ($this->isDenied()) { + return false; + } + + if (!$this->notifyAndWait()) { + return false; + } + + // Ensure the authAttempt is now accepted + $this->refresh(); + return $this->isAccepted(); + } +} diff --git a/src/app/CompanionApp.php b/src/app/CompanionApp.php new file mode 100644 --- /dev/null +++ b/src/app/CompanionApp.php @@ -0,0 +1,81 @@ + \config('firebase.api_verify_tls') + ] + ); + $response = $client->request( + 'POST', + \config('firebase.api_url'), + [ + 'headers' => [ + 'Authorization' => "key={$apiKey}", + ], + 'json' => [ + 'registration_ids' => $deviceIds, + 'data' => $data + ] + ] + ); + + + if ($response->getStatusCode() != 200) { + throw new \Exception('FCM Send Error: ' . $response->getStatusCode()); + } + return true; + } + + /** + * Send a notification to a user. + * + * @return bool true if a notification has been sent + */ + public static function notifyUser($userId, $data): bool + { + $notificationTokens = \App\CompanionApp::where('user_id', $userId) + ->where('mfa_enabled', true) + ->pluck('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/AuthAttempt/DeleteCommand.php b/src/app/Console/Commands/AuthAttempt/DeleteCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/AuthAttempt/DeleteCommand.php @@ -0,0 +1,35 @@ +argument('id')); + $authAttempt->delete(); + } +} diff --git a/src/app/Console/Commands/AuthAttempt/ListCommand.php b/src/app/Console/Commands/AuthAttempt/ListCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/AuthAttempt/ListCommand.php @@ -0,0 +1,40 @@ +each( + function ($authAttempt) { + $msg = var_export($authAttempt->toArray(), true); + $this->info($msg); + } + ); + } +} diff --git a/src/app/Console/Commands/AuthAttempt/PurgeCommand.php b/src/app/Console/Commands/AuthAttempt/PurgeCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/AuthAttempt/PurgeCommand.php @@ -0,0 +1,36 @@ +subDays(30); + \App\AuthAttempt::where('updated_at', '<', $cutoff) + ->delete(); + } +} diff --git a/src/app/Http/Controllers/API/V4/AuthAttemptsController.php b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php @@ -0,0 +1,116 @@ +user(); + if ($user->id != $authAttempt->user_id) { + return $this->errorResponse(403); + } + + \Log::debug("Confirm on {$authAttempt->id}"); + $authAttempt->accept(); + return response()->json([], 200); + } + + /** + * Deny the authentication attempt. + * + * @param string $id Id of AuthAttempt attempt + * + * @return \Illuminate\Http\JsonResponse + */ + 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(); + return response()->json([], 200); + } + + /** + * Return details of authentication attempt. + * + * @param string $id Id of AuthAttempt attempt + * + * @return \Illuminate\Http\JsonResponse + */ + 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 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php @@ -0,0 +1,67 @@ +user(); + if (!$user) { + return response()->json( + ['status' => 'error', 'message' => "Authentication required"], + 401 + ); + } + + $v = Validator::make( + $request->all(), + [ + 'notificationToken' => 'required|min:4', + 'deviceId' => 'required|min:4', + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $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; + } else { + //FIXME this allows a user to probe for another users deviceId + if ($app->user_id != $user->id) { + return response()->json( + ['status' => 'error', 'message' => "User mismatch on device registration: Expected {$user->id} but found {$app->user_id}"], + 401 + ); + } + } + + $app->notification_token = $notificationToken; + $app->save(); + + return response()->json(['status' => 'success']); + } +} diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -0,0 +1,213 @@ + + * 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 password, otherwise bye bye + $password = $request->headers->get('Auth-Pass', null); + + if (empty($password)) { + return $this->byebye($request, "Empty password"); + } + + $clientIP = $request->headers->get('Client-Ip', null); + + if (empty($clientIP)) { + return $this->byebye($request, "No client ip"); + } + + // 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) + + if (!Hash::check($password, $user->password)) { + $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); + // Avoid setting a passowrd failure reason if previously accepted the location. + 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)); + + if (!empty($countryCodes)) { + $country = \App\Utils::countryForIP($clientIP); + if (!in_array($country, $countryCodes)) { + \Log::info( + "Failed authentication attempt due to country code mismatch ({$country}) for user: {$login}" + ); + $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); + $attempt->deny(); + $attempt->reason = \App\AuthAttempt::REASON_GEOLOCATION; + $attempt->save(); + $attempt->notify(); + 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/config/firebase.php b/src/config/firebase.php new file mode 100644 --- /dev/null +++ b/src/config/firebase.php @@ -0,0 +1,7 @@ + Project Settings -> CLOUD MESSAGING -> Server key*/ + 'api_key' => env('FIREBASE_API_KEY'), + 'api_url' => env('FIREBASE_API_URL', 'https://fcm.googleapis.com/fcm/send'), + 'api_verify_tls' => (bool) env('FIREBASE_API_VERIFY_TLS', true) + ]; diff --git a/src/config/imap.php b/src/config/imap.php --- a/src/config/imap.php +++ b/src/config/imap.php @@ -5,5 +5,10 @@ '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 --- /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 --- /dev/null +++ b/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php @@ -0,0 +1,46 @@ +bigIncrements('id'); + $table->bigInteger('user_id'); + $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('updated_at'); + $table->unique(['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 --- /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'); + $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 --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -100,7 +100,10 @@ 'first_name' => 'Edward', 'last_name' => 'Flanders', 'currency' => 'USD', - 'country' => 'US' + 'country' => 'US', + // 'limit_geo' => json_encode(["CH"]), + 'guam_enabled' => false, + '2fa_enabled' => true ] ); diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -54,6 +54,8 @@ } ); + + Route::group( [ 'domain' => \config('app.website_domain'), @@ -61,6 +63,13 @@ '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'); @@ -140,6 +149,7 @@ function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); + Route::get('nginx', 'API\V4\NGINXController@authenticate'); } ); diff --git a/src/tests/Feature/Controller/AuthAttemptsTest.php b/src/tests/Feature/Controller/AuthAttemptsTest.php new file mode 100644 --- /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/CompanionAppsTest.php b/src/tests/Feature/Controller/CompanionAppsTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/CompanionAppsTest.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 --- /dev/null +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -0,0 +1,118 @@ +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(); + + $response = $this->actingAs($john)->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + } +}