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 @@ -73,6 +73,74 @@ proxy_set_header Host $host; } + location /roundcubemail { + proxy_pass http://127.0.0.1:9080; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + + location /kolab-webadmin { + proxy_pass http://127.0.0.1:9080; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + + location /Microsoft-Server-ActiveSync { + 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; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_send_timeout 910s; + proxy_read_timeout 910s; + fastcgi_send_timeout 910s; + fastcgi_read_timeout 910s; + } + + location ~* ^/\\.well-known/(caldav|carddav) { + proxy_pass http://127.0.0.1:9080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /iRony { + 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; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /auth { + internal; + 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 ""; + proxy_set_header X-Original-URI $request_uri; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + error_page 404 /404.html; location = /40x.html { } 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,33 @@ class NGINXController extends Controller { /** - * Authentication request. + * Authorize with the provided credentials. * - * @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 string $login The login name + * @param string $password The password + * @param string $clientIP The client ip * - * @param \Illuminate\Http\Request $request The API request. + * @return \App\User The user * - * @return \Illuminate\Http\Response The response + * @throws \Exception If the authorization fails. */ - 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 +50,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 +67,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 +78,126 @@ 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; + } + + + /** + * 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 + */ + + \Log::debug("Authentication attempt\n{$request->headers}"); + + $username = $this->normalizeUsername($request->headers->get('Php-Auth-User', "")); + $password = $request->headers->get('Php-Auth-Pw', null); + + 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 + */ + + \Log::debug("Authentication attempt\n{$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 @@ -169,6 +169,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,84 @@ $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); + + // 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(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); + } }