diff --git a/src/app/Auth/Utils.php b/src/app/Auth/Utils.php new file mode 100644 --- /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 --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers\API\V4; +use App\Auth\Utils as AuthUtils; use App\Http\Controllers\Controller; +use App\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -28,7 +30,16 @@ throw new \Exception("Empty password"); } - $user = \App\User::where('email', $login)->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"); } @@ -65,7 +76,16 @@ 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"); 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 @@ -2,6 +2,7 @@ namespace Tests\Feature\Controller; +use App\Auth\Utils as AuthUtils; use Tests\TestCase; class NGINXTest extends TestCase @@ -129,7 +130,6 @@ $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); - // Guam $john->setSettings(['guam_enabled' => 'true']); @@ -139,7 +139,6 @@ $response->assertHeader('auth-server', gethostbyname(\config('imap.host'))); $response->assertHeader('auth-port', \config('imap.guam_port')); - $companionApp = $this->getTestCompanionApp( 'testdevice', $john, @@ -170,7 +169,6 @@ $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); - // Geo-lockin (failure) $john->setSettings(['limit_geo' => '["PL","US"]']); @@ -200,6 +198,19 @@ $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'); } /** @@ -289,4 +300,20 @@ $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 --- /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')); + } +}