diff --git a/src/app/Auth/Utils.php b/src/app/Auth/Utils.php new file mode 100644 index 00000000..6f061da6 --- /dev/null +++ b/src/app/Auth/Utils.php @@ -0,0 +1,100 @@ +addSeconds(10)->format('YmdHis'); + + $value = openssl_encrypt($data, $cipher, $key, 0, $iv, $tag); + + if ($value === false) { + return null; + } + + return trim(base64_encode($iv), '=') + . '!' + . trim(base64_encode($tag), '=') + . '!' + . trim(base64_encode($value), '='); + } + + /** + * Vaidate a simple authentication token + * + * @param string $token Token + * + * @return string|null User identifier, Null on failure + */ + public static function tokenValidate($token): ?string + { + if (!preg_match('|^[a-zA-Z0-9!+/]{50,}$|', $token)) { + // this isn't a token, probably a normal password + return null; + } + + [$iv, $tag, $payload] = explode('!', $token); + + $iv = base64_decode($iv); + $tag = base64_decode($tag); + $payload = base64_decode($payload); + + $cipher = strtolower(config('app.cipher')); + $key = config('app.key'); + + $decrypted = openssl_decrypt($payload, $cipher, $key, 0, $iv, $tag); + + if ($decrypted === false) { + return null; + } + + $payload = explode('!', $decrypted); + + if (count($payload) != 2 + || !preg_match('|^[0-9]+$|', $payload[0]) + || !preg_match('|^[0-9]{14}+$|', $payload[1]) + ) { + // Invalid payload format + return null; + } + + // Check expiration date + try { + $expiry = Carbon::create( + (int) substr($payload[1], 0, 4), + (int) substr($payload[1], 4, 2), + (int) substr($payload[1], 6, 2), + (int) substr($payload[1], 8, 2), + (int) substr($payload[1], 10, 2), + (int) substr($payload[1], 12, 2) + ); + + if (now() > $expiry) { + return null; + } + } catch (\Exception $e) { + return null; + } + + return $payload[0]; + } +} diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php index ff16b5f3..ca5cf117 100644 --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -1,363 +1,383 @@ first(); + if ($userid = AuthUtils::tokenValidate($password)) { + $user = User::find($userid); + if ($user && $user->email == $login) { + return $user; + } + + throw new \Exception("Password mismatch"); + } + + $user = User::where('email', $login)->first(); if (!$user) { throw new \Exception("User not found"); } if (!Hash::check($password, $user->password)) { throw new \Exception("Password mismatch"); } return $user; } /** * Authorize with the provided credentials. * * @param string $login The login name * @param string $password The password * @param string $clientIP The client ip * * @return \App\User The user * * @throws \Exception If the authorization fails. */ private function authorizeRequest($login, $password, $clientIP) { if (empty($login)) { throw new \Exception("Empty login"); } if (empty($password)) { throw new \Exception("Empty password"); } if (empty($clientIP)) { throw new \Exception("No client ip"); } - $result = \App\User::findAndAuthenticate($login, $password, $clientIP); + if ($userid = AuthUtils::tokenValidate($password)) { + $user = User::find($userid); + if ($user && $user->email == $login) { + return $user; + } + + throw new \Exception("Password mismatch"); + } + + $result = User::findAndAuthenticate($login, $password, $clientIP); if (empty($result['user'])) { throw new \Exception($result['errorMessage'] ?? "Unknown error"); } // 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: Apply some sort of limit for Auth-Login-Attempt -- docs say it is the number of // attempts over the same authAttempt. return $result['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); $ip = $request->headers->get('X-Real-Ip', 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, $ip); } catch (\Exception $e) { \Log::debug("Authentication attempt failed: {$e->getMessage()}"); return response("", 403); } \Log::debug("Authentication attempt succeeded"); return response(''); } /** * Authentication request from the cyrus sasl * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function cyrussasl(Request $request) { $data = $request->getContent(); // Assumes "%u %r %p" as form data in the cyrus sasl config file $array = explode(' ', rawurldecode($data)); if (count($array) != 3) { \Log::debug("Authentication attempt failed: invalid data provided."); return response("", 403); } $username = $array[0]; $realm = $array[1]; $password = $array[2]; if (!empty($realm)) { $username = "$username@$realm"; } if (empty($password)) { \Log::debug("Authentication attempt failed: Empty password provided."); return response('', 403); } try { $this->authorizeRequestCredentialsOnly($username, $password); } catch (\Exception $e) { \Log::debug("Authentication attempt failed for $username: {$e->getMessage()}"); return response('', 403); } \Log::debug("Authentication attempt succeeded for $username"); 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 Whether 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" => gethostbyname(\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" => gethostbyname(\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 10f20ff0..c8ae654e 100644 --- a/src/tests/Feature/Controller/NGINXTest.php +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -1,292 +1,319 @@ 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', \config('imap.imap_port')); // 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', gethostbyname(\config('smtp.host'))); $response->assertHeader('auth-port', \config('smtp.port')); $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', gethostbyname(\config('imap.host'))); $response->assertHeader('auth-port', \config('imap.guam_port')); - $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()); + + // Token auth (valid) + $modifiedHeaders['Auth-Pass'] = AuthUtils::tokenCreate($john->id); + $modifiedHeaders['Auth-Protocol'] = 'smtp'; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); + + // Token auth (invalid payload) + $modifiedHeaders['Auth-User'] = 'jack@kolab.org'; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); } /** * Test the httpauth webhook */ public function testNGINXHttpAuthHook(): void { $john = $this->getTestUser('john@kolab.org'); $response = $this->get("api/webhooks/nginx-httpauth"); $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(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); } + + /** + * Test the roundcube webhook + */ + public function testRoundcubeHook(): void + { + $this->markTestIncomplete(); + } + + /** + * Test the cyrus-sasl webhook + */ + public function testCyrusSaslHook(): void + { + $this->markTestIncomplete(); + } } diff --git a/src/tests/Unit/Auth/UtilsTest.php b/src/tests/Unit/Auth/UtilsTest.php new file mode 100644 index 00000000..e9fbd486 --- /dev/null +++ b/src/tests/Unit/Auth/UtilsTest.php @@ -0,0 +1,31 @@ +assertTrue(strlen($token) > 50 && strlen($token) < 128); + $this->assertTrue(preg_match('|^[a-zA-Z0-9!+/]+$|', $token) === 1); + + $this->assertSame($userid, Utils::tokenValidate($token)); + + // Expired token + Carbon::setTestNow(Carbon::now()->addSeconds(11)); + $this->assertNull(Utils::tokenValidate($token)); + + // Invalid token + $this->assertNull(Utils::tokenValidate('sdfsdfsfsdfsdfs!asd!sdfsdfsdfrwet')); + } +}