diff --git a/docker/proxy/rootfs/etc/nginx/nginx.conf b/docker/proxy/rootfs/etc/nginx/nginx.conf --- a/docker/proxy/rootfs/etc/nginx/nginx.conf +++ b/docker/proxy/rootfs/etc/nginx/nginx.conf @@ -109,8 +109,9 @@ proxy_cache_bypass 1; } + # FIXME do we need to whitelist certain requests that are unauthenticated? location /Microsoft-Server-ActiveSync { - #auth_request /auth; + auth_request /auth; #auth_request_set $auth_status $upstream_status; proxy_pass http://127.0.0.1:9080; @@ -124,9 +125,6 @@ } location ~* ^/\\.well-known/(caldav|carddav) { - #auth_request /auth; - #auth_request_set $auth_status $upstream_status; - proxy_pass http://127.0.0.1:9080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -134,7 +132,7 @@ } location /iRony { - #auth_request /auth; + auth_request /auth; #auth_request_set $auth_status $upstream_status; proxy_pass http://127.0.0.1:9080; @@ -145,7 +143,7 @@ location = /auth { internal; - proxy_pass http://127.0.0.1:8000/api/webhooks/nginx; + proxy_pass http://127.0.0.1:8000/api/webhooks/nginx-httpauth; proxy_pass_request_body off; proxy_set_header Host services.APP_WEBSITE_DOMAIN; proxy_set_header Content-Length ""; diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -10,62 +10,29 @@ class NGINXController extends Controller { /** - * 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? + * Authorize with the provided credentials. * - * @param \Illuminate\Http\Request $request The API request. + * @throws \Exception $e If the authorization fails. * - * @return \Illuminate\Http\Response The response + * @return \App\User The user */ - public function authenticate(Request $request) + private function authorizeRequest($login, $password, $clientIP) { - /** - * 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"); + throw new \Exception("Empty login"); } - // validate password, otherwise bye bye - $password = $request->headers->get('Auth-Pass', null); - if (empty($password)) { - return $this->byebye($request, "Empty password"); + throw new \Exception("Empty password"); } - $clientIP = $request->headers->get('Client-Ip', null); - if (empty($clientIP)) { - return $this->byebye($request, "No client ip"); + throw new \Exception("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"); + throw new \Exception("User not found"); } // TODO: validate the user's domain is A-OK (active, confirmed, not suspended, ldapready) @@ -79,8 +46,7 @@ $attempt->save(); $attempt->notify(); } - \Log::info("Failed authentication attempt due to password mismatch for user: {$login}"); - return $this->byebye($request, "Password mismatch"); + throw new \Exception("Password mismatch"); } // validate country of origin against restrictions, otherwise bye bye @@ -97,7 +63,7 @@ $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); $attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION); $attempt->notify(); - return $this->byebye($request, "Country code mismatch"); + throw new \Exception("Country code mismatch"); } } @@ -108,9 +74,104 @@ if ($user->getSetting('2fa_enabled', false)) { $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); if (!$authAttempt->waitFor2FA()) { - return $this->byebye($request, "2fa failed"); + throw new \Exception("2fa failed"); } } + return $user; + } + + /** + * 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 + */ + + \Log::debug("Authentication attempt"); + \Log::debug($request->headers); + + try { + $this->authorizeRequest( + $request->headers->get('Php-Auth-User', null), + $request->headers->get('Php-Auth-Pw', null), + $request->headers->get('X-Real-Ip', null), + ); + } catch (\Exception $e) { + \Log::debug("Authentication attempt failed: {$e->getMessage()}"); + return $this->errorResponse(403); + } + + \Log::debug("Authentication attempt succeeded"); + $response = response("")->withHeaders( + [ + "Auth-Status" => "OK" + ] + ); + 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 + */ + + \Log::debug("Authentication attempt"); + \Log::debug($request->headers); + + $password = $request->headers->get('Auth-Pass', null); + + try { + $user = $this->authorizeRequest( + $request->headers->get('Auth-User', null), + $password, + $request->headers->get('Client-Ip', null), + ); + } catch (\Exception $e) { + return $this->byebye($request, $e->getMessage()); + } // All checks passed switch ($request->headers->get('Auth-Protocol')) { diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -146,6 +146,7 @@ ], function () { Route::get('nginx', 'API\V4\NGINXController@authenticate'); + Route::get('nginx-httpauth', 'API\V4\NGINXController@httpauth'); 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/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php --- a/src/tests/Feature/Controller/NGINXTest.php +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -163,4 +163,78 @@ $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); } + + /** + * Test the httpauth webhook + */ + public function testNGINXHttpAuthHook(): void + { + $john = $this->getTestUser('john@kolab.org'); + + $response = $this->get("api/webhooks/nginx-httpauth"); + $response->assertStatus(403); + + $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); + + // 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(403); + + // Empty User + $modifiedHeaders = $headers; + $modifiedHeaders['Php-Auth-User'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); + $response->assertStatus(403); + + // 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); + + + // 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-httpauth"); + $response->assertStatus(403); + + // 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); + } }