diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php index 8fd3ee4c..3a247992 100644 --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -1,326 +1,331 @@ first(); if (!$user) { throw new \Exception("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) // TODO: we could use User::findAndAuthenticate() with some modifications here 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(); } throw new \Exception("Password mismatch"); } // validate country of origin against restrictions, otherwise bye bye if (!$user->validateLocation($clientIP)) { \Log::info("Failed authentication attempt due to country code mismatch for user: {$login}"); $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); $attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION); $attempt->notify(); throw new \Exception("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 (\App\CompanionApp::where('user_id', $user->id)->exists()) { $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); if (!$authAttempt->waitFor2FA()) { throw new \Exception("2fa failed"); } } return $user; } /** * Convert domain.tld\username into username@domain for activesync * * @param string $username The original username. * * @return string The username in canonical form */ private function normalizeUsername($username) { $usernameParts = explode("\\", $username); if (count($usernameParts) == 2) { $username = $usernameParts[1]; if (!strpos($username, '@') && !empty($usernameParts[0])) { $username .= '@' . $usernameParts[0]; } } return $username; } /** * Authentication request from the ngx_http_auth_request_module * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function httpauth(Request $request) { /** Php-Auth-Pw: simple123 Php-Auth-User: john@kolab.org Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: cross-site Sec-Gpc: 1 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0 X-Forwarded-For: 31.10.153.58 X-Forwarded-Proto: https X-Original-Uri: /iRony/ X-Real-Ip: 31.10.153.58 */ $username = $this->normalizeUsername($request->headers->get('Php-Auth-User', "")); $password = $request->headers->get('Php-Auth-Pw', null); + if (empty($username)) { + //Allow unauthenticated requests + return response(""); + } + if (empty($password)) { \Log::debug("Authentication attempt failed: Empty password provided."); return response("", 401); } try { $this->authorizeRequest( $username, $password, $request->headers->get('X-Real-Ip', null), ); } catch (\Exception $e) { \Log::debug("Authentication attempt failed: {$e->getMessage()}"); return response("", 403); } \Log::debug("Authentication attempt succeeded"); return response(""); } /** * Authentication request. * * @todo: Separate IMAP(+STARTTLS) from IMAPS, same for SMTP/submission. => * 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 */ $password = $request->headers->get('Auth-Pass', null); $username = $request->headers->get('Auth-User', null); $ip = $request->headers->get('Client-Ip', null); try { $user = $this->authorizeRequest( $username, $password, $ip, ); } catch (\Exception $e) { return $this->byebye($request, $e->getMessage()); } // All checks passed switch ($request->headers->get('Auth-Protocol')) { case "imap": return $this->authenticateIMAP($request, (bool) $user->getSetting('guam_enabled'), $password); case "smtp": return $this->authenticateSMTP($request, $password); default: return $this->byebye($request, "unknown protocol in request"); } } /** * Authentication request for roundcube imap. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function authenticateRoundcube(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 */ $password = $request->headers->get('Auth-Pass', null); $username = $request->headers->get('Auth-User', null); $ip = $request->headers->get('Proxy-Protocol-Addr', null); try { $user = $this->authorizeRequest( $username, $password, $ip, ); } catch (\Exception $e) { return $this->byebye($request, $e->getMessage()); } // All checks passed switch ($request->headers->get('Auth-Protocol')) { case "imap": return $this->authenticateIMAP($request, false, $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 ] ); 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 ] ); 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 ] ); return $response; } } diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php index 11f58662..9578a6f6 100644 --- a/src/tests/Feature/Controller/NGINXTest.php +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -1,292 +1,292 @@ getTestUser('john@kolab.org'); \App\CompanionApp::where('user_id', $john->id)->delete(); \App\AuthAttempt::where('user_id', $john->id)->delete(); $john->setSettings([ 'limit_geo' => null, 'guam_enabled' => null, ]); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $this->useServicesUrl(); } /** * {@inheritDoc} */ 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' => null, 'guam_enabled' => null, ]); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); 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'); $companionApp = $this->getTestCompanionApp( 'testdevice', $john, [ 'notification_token' => 'notificationtoken', 'mfa_enabled' => 1, 'name' => 'testname', ] ); // 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'); // Deny $authAttempt->deny(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // 2-FA without device $companionApp->delete(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); // Geo-lockin (failure) $john->setSettings(['limit_geo' => '["PL","US"]']); $headers['Auth-Protocol'] = 'imap'; $headers['Client-Ip'] = '127.0.0.1'; $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); $authAttempt = \App\AuthAttempt::where('ip', $headers['Client-Ip'])->where('user_id', $john->id)->first(); $this->assertSame('geolocation', $authAttempt->reason); \App\AuthAttempt::where('user_id', $john->id)->delete(); // Geo-lockin (success) \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); $this->assertCount(0, \App\AuthAttempt::where('user_id', $john->id)->get()); } /** * Test the httpauth webhook */ public function testNGINXHttpAuthHook(): void { $john = $this->getTestUser('john@kolab.org'); $response = $this->get("api/webhooks/nginx-httpauth"); - $response->assertStatus(401); + $response->assertStatus(200); $pass = \App\Utils::generatePassphrase(); $headers = [ 'Php-Auth-Pw' => $pass, 'Php-Auth-User' => 'john@kolab.org', 'X-Forwarded-For' => '127.0.0.1', 'X-Forwarded-Proto' => 'https', 'X-Original-Uri' => '/iRony/', 'X-Real-Ip' => '127.0.0.1', ]; // Pass $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // domain.tld\username $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = "kolab.org\\john"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // Invalid Password $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-Pw'] = "Invalid"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Empty Password $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-Pw'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(401); // Empty User $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); - $response->assertStatus(403); + $response->assertStatus(200); // Invalid User $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = "foo@kolab.org"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Empty Ip $modifiedHeaders = $headers; $modifiedHeaders['X-Real-Ip'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); $companionApp = $this->getTestCompanionApp( 'testdevice', $john, [ 'notification_token' => 'notificationtoken', 'mfa_enabled' => 1, 'name' => 'testname', ] ); // 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-httpauth"); $response->assertStatus(200); // Deny $authAttempt->deny(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // 2-FA without device $companionApp->delete(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); } }