diff --git a/docker-compose.yml b/docker-compose.yml --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,11 +74,8 @@ build: context: ./docker/nginx/ args: - NGINX_AUTH_WEBHOOK: ${APP_DOMAIN}/api/webhooks/nginx + APP_WEBSITE_DOMAIN: ${APP_WEBSITE_DOMAIN:?err} container_name: kolab-nginx - depends_on: - kolab: - condition: service_healthy hostname: nginx.hosted.com image: kolab-nginx network_mode: host @@ -89,10 +86,8 @@ - /var/tmp tty: true volumes: - - /etc/letsencrypt/:/etc/letsencrypt/:ro - ./docker/certs/imap.hosted.com.cert:/etc/pki/tls/certs/imap.hosted.com.cert - ./docker/certs/imap.hosted.com.key:/etc/pki/tls/private/imap.hosted.com.key - - /sys/fs/cgroup:/sys/fs/cgroup:ro openvidu: build: context: ./docker/openvidu/ diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -1,54 +1,25 @@ -FROM fedora:31 +FROM fedora:34 MAINTAINER Jeroen van Meeuwen ENV container docker -ENV SYSTEMD_PAGER='' - -ARG NGINX_AUTH_WEBHOOK RUN dnf -y install \ --setopt 'tsflags=nodocs' \ - bash-completion \ - bind-utils \ - certbot \ - curl \ - dhcp-client \ - git \ - iproute \ - iptraf-ng \ - iputils \ - less \ - lsof \ - mtr \ - net-tools \ - NetworkManager \ - NetworkManager-tui \ - network-scripts \ nginx \ - nginx-mod-mail \ - nmap-ncat \ - openssh-clients \ - openssh-server \ - procps-ng \ - python3-certbot-nginx \ - strace \ - systemd-udev \ - tcpdump \ - telnet \ - traceroute \ - vim-enhanced \ - wget && \ + nginx-mod-mail && \ dnf clean all -RUN sed -i -r -e 's/^SELINUX=.*$/SELINUX=permissive/g' /etc/selinux/config 2>/dev/null || : - COPY nginx.conf /etc/nginx/nginx.conf -RUN sed -i -r -e "s|^.*auth_http.*$| auth_http $NGINX_AUTH_WEBHOOK;|g" /etc/nginx/nginx.conf +ARG APP_WEBSITE_DOMAIN +RUN sed -i -r -e "s|^.*auth_http_header.*$| auth_http_header Host services.$APP_WEBSITE_DOMAIN;|g" /etc/nginx/nginx.conf + +# Forward request logs to Docker log collector +RUN ln -sf /dev/stdout /var/log/nginx/access.log \ + && ln -sf /dev/stderr /var/log/nginx/error.log -RUN systemctl enable nginx +STOPSIGNAL SIGTERM -CMD ["/lib/systemd/systemd", "--system"] -ENTRYPOINT "/lib/systemd/systemd" +CMD ["nginx", "-g", "daemon off;"] EXPOSE 110/tcp 143/tcp 993/tcp 995/tcp diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -11,8 +11,9 @@ } mail { - server_name imap.hosted.com; - auth_http 127.0.0.1:8000/api/webhooks/nginx; + server_name imap.hosted.com; + auth_http 127.0.0.1:8000/api/webhooks/nginx; + auth_http_header Host 127.0.0.1; proxy_pass_error_message on; diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -6,6 +6,7 @@ #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com +APP_WEBSITE_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en 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($reason = AuthAttempt::REASON_NONE) + { + $this->expires_at = Carbon::now()->addHours(8); + $this->status = self::STATUS_ACCEPTED; + $this->reason = $reason; + $this->save(); + } + + /** + * Deny the authentication attempt. + */ + public function deny($reason = AuthAttempt::REASON_NONE) + { + $this->status = self::STATUS_DENIED; + $this->reason = $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 Returns true if the attempt is accepted on confirmation + */ + public function waitFor2FA(): bool + { + if ($this->isAccepted()) { + return true; + } + if ($this->isDenied()) { + return false; + } + + if (!$this->notifyAndWait()) { + return false; + } + + 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,83 @@ + \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. + * + * @throws \Exception on notification failure + * @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,15 @@ +each( + function ($authAttempt) { + $this->info($authAttempt->toJson(JSON_PRETTY_PRINT)); + } + ); + } +} 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); + 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,120 @@ +errorResponse(404); + } + + $user = $this->guard()->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::find($id); + if (!$authAttempt) { + return $this->errorResponse(404); + } + + $user = $this->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::find($id); + if (!$authAttempt) { + return $this->errorResponse(404); + } + + $user = $this->guard()->user(); + if ($user->id != $authAttempt->user_id) { + return $this->errorResponse(403); + } + + return response()->json([ + 'status' => 'success', + 'username' => $user->email, + '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 = $this->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,58 @@ +guard()->user(); + + $v = Validator::make( + $request->all(), + [ + 'notificationToken' => 'required|min:4|max:512', + 'deviceId' => 'required|min:4|max:64', + ] + ); + + 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) { + \Log::warning("User mismatch on device registration. Expected {$user->id} but found {$app->user_id}"); + return $this->errorResponse(403); + } + } + + $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,203 @@ + + * 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::debug("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 password failure reason if we 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(\App\AuthAttempt::REASON_GEOLOCATION); + $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) { + $port = \config('imap.guam_port'); + } else { + $port = \config('imap.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" => "authentication failure", + "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,8 @@ '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'), + 'imap_port' => env('IMAP_PORT', 12143), + 'guam_port' => env('IMAP_GUAM_PORT', 9143), ]; 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,45 @@ +bigIncrements('id'); + $table->bigInteger('user_id'); + // Seems to grow over time, no clear specification. + // Typically below 200 bytes, but some mention up to 350 bytes. + $table->string('notification_token', 512)->nullable(); + // 16 byte for android, 36 for ios. May change over tyme + $table->string('device_id', 64); + $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/phpstan.neon b/src/phpstan.neon --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -2,7 +2,6 @@ - ./vendor/nunomaduro/larastan/extension.neon parameters: ignoreErrors: - - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#' - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#' 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'); @@ -147,12 +156,13 @@ Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), - 'prefix' => $prefix . 'api/webhooks/policy' + 'prefix' => $prefix . 'api/webhooks' ], function () { - Route::post('greylist', 'API\V4\PolicyController@greylist'); - Route::post('ratelimit', 'API\V4\PolicyController@ratelimit'); - Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework'); + Route::get('nginx', 'API\V4\NGINXController@authenticate'); + Route::post('policy/greylist', 'API\V4\PolicyController@greylist'); + Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit'); + Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework'); } ); } diff --git a/src/tests/Feature/AuthAttemptTest.php b/src/tests/Feature/AuthAttemptTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/AuthAttemptTest.php @@ -0,0 +1,40 @@ +deleteTestUser('jane@kolabnow.com'); + } + + public function tearDown(): void + { + $this->deleteTestUser('jane@kolabnow.com'); + + parent::tearDown(); + } + + public function testRecord(): void + { + $user = $this->getTestUser('jane@kolabnow.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); + } +} diff --git a/src/tests/Feature/Console/AuthAttempt/DeleteTest.php b/src/tests/Feature/Console/AuthAttempt/DeleteTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/AuthAttempt/DeleteTest.php @@ -0,0 +1,47 @@ +getTestUser('john@kolab.org'); + $authAttempt = AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + $code = \Artisan::call("authattempt:delete {$authAttempt->id}"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertTrue(!AuthAttempt::find($authAttempt->id)); + + // AuthAttempt not existing + $code = \Artisan::call("authattempt:delete 999"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("No such authattempt 999", $output); + } +} diff --git a/src/tests/Feature/Console/AuthAttempt/ListTest.php b/src/tests/Feature/Console/AuthAttempt/ListTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/AuthAttempt/ListTest.php @@ -0,0 +1,51 @@ +assertSame(0, $code); + $this->assertSame('', $output); + + $user = $this->getTestUser('john@kolab.org'); + $authAttempt = AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + //For up-to date timestamps and whatnot + $authAttempt->refresh(); + + $code = \Artisan::call("authattempt:list"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + + $this->assertSame($authAttempt->toJson(JSON_PRETTY_PRINT), $output); + } +} diff --git a/src/tests/Feature/Console/AuthAttempt/PurgeTest.php b/src/tests/Feature/Console/AuthAttempt/PurgeTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/AuthAttempt/PurgeTest.php @@ -0,0 +1,57 @@ +subDays(30); + + $user = $this->getTestUser('john@kolab.org'); + + $authAttempt1 = AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); + $authAttempt1->refresh(); + $authAttempt1->updated_at = $cutoff->copy()->addDays(1); + $authAttempt1->save(['timestamps' => false]); + + $authAttempt2 = AuthAttempt::recordAuthAttempt($user, "10.0.0.2"); + $authAttempt2->refresh(); + $authAttempt2->updated_at = $cutoff->copy()->subDays(1); + $authAttempt2->save(['timestamps' => false]); + + $code = \Artisan::call('authattempt:purge'); + $this->assertSame(0, $code); + + $list = AuthAttempt::all(); + $this->assertCount(1, $list); + $this->assertSame($authAttempt1->id, $list[0]->id); + } +} 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,131 @@ +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 cofirm (POST /api/v4/auth-attempts//confirm) + */ + public function testAccept(): 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()); + + // wrong user + $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); + $response = $this->actingAs($user2)->post("api/v4/auth-attempts/{$authAttempt->id}/confirm"); + $response->assertStatus(403); + + // wrong id + $response = $this->actingAs($user)->post("api/v4/auth-attempts/9999/confirm"); + $response->assertStatus(404); + } + + + /** + * Test deny (POST /api/v4/auth-attempts//deny) + */ + public function testDeny(): 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}/deny"); + $response->assertStatus(200); + $authAttempt->refresh(); + $this->assertTrue($authAttempt->isDenied()); + + // wrong user + $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); + $response = $this->actingAs($user2)->post("api/v4/auth-attempts/{$authAttempt->id}/deny"); + $response->assertStatus(403); + + // wrong id + $response = $this->actingAs($user)->post("api/v4/auth-attempts/9999/deny"); + $response->assertStatus(404); + } + + + /** + * Test details (GET /api/v4/auth-attempts//details) + */ + 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['entry']['ip']); + $this->assertEquals(json_encode($authAttempt->updated_at), "\"" . $json['entry']['updated_at'] . "\""); + $this->assertEquals("CH", $json['country']); + + // wrong user + $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); + $response = $this->actingAs($user2)->get("api/v4/auth-attempts/{$authAttempt->id}/details"); + $response->assertStatus(403); + + // wrong id + $response = $this->actingAs($user)->get("api/v4/auth-attempts/9999/details"); + $response->assertStatus(404); + } + + + /** + * Test list (GET /api/v4/auth-attempts) + */ + 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(); + + $this->assertCount(2, $json); + $this->assertTrue(in_array($json[0]['id'], [$authAttempt->id, $authAttempt2->id])); + $this->assertTrue(in_array($json[1]['id'], [$authAttempt->id, $authAttempt2->id])); + $this->assertTrue($json[0]['id'] != $json[1]['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,82 @@ +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); + + // Failing input valdiation + $response = $this->actingAs($user)->post( + "api/v4/companion/register", + [] + ); + $response->assertStatus(422); + + // Other users device + $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com'); + $response = $this->actingAs($user2)->post( + "api/v4/companion/register", + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ); + $response->assertStatus(403); + } +} 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,166 @@ +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 + ] + ); + $this->useServicesUrl(); + } + + 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->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + $pass = \App\Utils::generatePassphrase(); + $headers = [ + 'Auth-Login-Attempt' => '1', + 'Auth-Method' => 'plain', + 'Auth-Pass' => $pass, + '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->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + $response->assertHeader('auth-port', '12143'); + + // Invalid Password + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-Pass'] = "Invalid"; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // Empty Password + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-Pass'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // Empty User + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-User'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // Invalid User + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-User'] = "foo@kolab.org"; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // Empty Ip + $modifiedHeaders = $headers; + $modifiedHeaders['Client-Ip'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // SMTP Auth Protocol + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-Protocol'] = "smtp"; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + $response->assertHeader('auth-server', '127.0.0.1'); + $response->assertHeader('auth-port', '10465'); + $response->assertHeader('auth-pass', $pass); + + // Empty Auth Protocol + $modifiedHeaders = $headers; + $modifiedHeaders['Auth-Protocol'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + + // Guam + $john->setSettings( + [ + 'guam_enabled' => true, + ] + ); + + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + $response->assertHeader('auth-server', '127.0.0.1'); + $response->assertHeader('auth-port', '9143'); + + // 2-FA without device + $john->setSettings( + [ + '2fa_enabled' => true, + ] + ); + \App\CompanionApp::where('user_id', $john->id)->delete(); + + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // 2-FA with accepted auth attempt + $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); + $authAttempt->accept(); + + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + } +}