diff --git a/.gitignore b/.gitignore index 7a47d39bab..3b0c7fae27 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /public/js /public/css /storage/*.key +/storage/app/keys/ /storage/*.index /storage/tenant_* /vendor diff --git a/ProcessMaker/Console/Commands/GenerateLoginKeys.php b/ProcessMaker/Console/Commands/GenerateLoginKeys.php new file mode 100644 index 0000000000..12b1378671 --- /dev/null +++ b/ProcessMaker/Console/Commands/GenerateLoginKeys.php @@ -0,0 +1,29 @@ +hasKeyPair() && !$this->option('force')) { + $this->error('Login encryption keys already exist. Use --force to overwrite them.'); + + return self::FAILURE; + } + + $encryption->generateKeyPair(); + $this->info('Login encryption keys generated successfully.'); + + return self::SUCCESS; + } +} diff --git a/ProcessMaker/Http/Controllers/Auth/LoginController.php b/ProcessMaker/Http/Controllers/Auth/LoginController.php index ccdffe7a09..47498d220e 100644 --- a/ProcessMaker/Http/Controllers/Auth/LoginController.php +++ b/ProcessMaker/Http/Controllers/Auth/LoginController.php @@ -17,6 +17,7 @@ use ProcessMaker\Models\Setting; use ProcessMaker\Models\User; use ProcessMaker\Package\Auth\Database\Seeds\AuthDefaultSeeder; +use ProcessMaker\Services\LoginCredentialEncryption; use ProcessMaker\Services\PermissionCacheService; use ProcessMaker\Traits\HasControllerAddons; @@ -115,8 +116,10 @@ public function showLoginForm(Request $request) false, $this->sessionSameSite() ); + $loginPublicKey = app(LoginCredentialEncryption::class)->publicKeyForLogin(); + $loginView = empty(config('app.login_view')) ? 'auth.login' : config('app.login_view'); - $response = response(view($loginView, compact('addons', 'block'))); + $response = response(view($loginView, compact('addons', 'block', 'loginPublicKey'))); $response->withCookie($cookie); // Remove 'password_hash_web' from session @@ -194,6 +197,10 @@ protected function getColumnAttribute(object $setting, string $attribute, string public function loginWithIntendedCheck(Request $request) { + if (!app(LoginCredentialEncryption::class)->mergeDecryptedCredentials($request)) { + $this->sendFailedLoginResponse($request); + } + $intended = Cookie::get('processmaker_intended'); if ($intended) { // Check if the route is a fallback, meaning it's invalid (like favicon.ico) diff --git a/ProcessMaker/Services/LoginCredentialEncryption.php b/ProcessMaker/Services/LoginCredentialEncryption.php new file mode 100644 index 0000000000..2a4638155c --- /dev/null +++ b/ProcessMaker/Services/LoginCredentialEncryption.php @@ -0,0 +1,230 @@ +isEnabled()) { + return null; + } + + $this->ensureKeyPair(); + + return trim((string) file_get_contents($this->path(self::PUBLIC_KEY))); + } + + public function mergeDecryptedCredentials(Request $request): bool + { + if (!$request->boolean('encrypted')) { + return true; + } + + if (!$this->isEnabled() || !$this->hasKeyPair()) { + return false; + } + + $credentials = $this->decryptCredentials((string) $request->input('encrypted_credentials', '')); + if ($credentials === null) { + return false; + } + + $request->merge($credentials); + + return true; + } + + /** + * @return array{username: string, password: string}|null + */ + public function decryptCredentials(string $cipherTextBase64): ?array + { + $decoded = base64_decode($cipherTextBase64, true); + if ($decoded === false) { + return null; + } + + $bundle = json_decode($decoded, true); + if (is_array($bundle) && (int) ($bundle['v'] ?? 0) === self::HYBRID_VERSION) { + return $this->decryptHybridBundle($bundle); + } + + return $this->decryptLegacyRsaPayload($cipherTextBase64); + } + + public function encryptCredentials(string $username, string $password, ?int $timestamp = null): string + { + $this->ensureKeyPair(); + + $payload = json_encode([ + 'u' => $username, + 'p' => $password, + 't' => $timestamp ?? time(), + ], JSON_THROW_ON_ERROR); + + $aesKey = random_bytes(32); + $iv = random_bytes(12); + $tag = ''; + $ciphertext = openssl_encrypt($payload, 'aes-256-gcm', $aesKey, OPENSSL_RAW_DATA, $iv, $tag); + if ($ciphertext === false) { + throw new \RuntimeException('Unable to encrypt login credentials.'); + } + + $publicKey = openssl_pkey_get_public((string) file_get_contents($this->path(self::PUBLIC_KEY))); + $encryptedKey = ''; + if ( + $publicKey === false + || !openssl_public_encrypt($aesKey, $encryptedKey, $publicKey, OPENSSL_PKCS1_OAEP_PADDING) + ) { + throw new \RuntimeException('Unable to encrypt login credentials key.'); + } + + return base64_encode(json_encode([ + 'v' => self::HYBRID_VERSION, + 'k' => base64_encode($encryptedKey), + 'i' => base64_encode($iv), + 'd' => base64_encode($ciphertext . $tag), + ], JSON_THROW_ON_ERROR)); + } + + public function generateKeyPair(): void + { + $directory = storage_path(self::KEYS_DIR); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($key === false) { + throw new \RuntimeException('Unable to generate login RSA key pair.'); + } + + $privatePath = $this->path(self::PRIVATE_KEY); + if (!openssl_pkey_export_to_file($key, $privatePath)) { + throw new \RuntimeException('Unable to write login private key.'); + } + + $details = openssl_pkey_get_details($key); + if ($details === false || empty($details['key'])) { + throw new \RuntimeException('Unable to extract login public key.'); + } + + file_put_contents($this->path(self::PUBLIC_KEY), $details['key']); + chmod($privatePath, 0600); + } + + public function hasKeyPair(): bool + { + return is_readable($this->path(self::PRIVATE_KEY)) && is_readable($this->path(self::PUBLIC_KEY)); + } + + private function ensureKeyPair(): void + { + if (!$this->hasKeyPair()) { + $this->generateKeyPair(); + } + } + + /** + * @param array $bundle + * @return array{username: string, password: string}|null + */ + private function decryptHybridBundle(array $bundle): ?array + { + if (!isset($bundle['k'], $bundle['i'], $bundle['d'])) { + return null; + } + + $privateKey = openssl_pkey_get_private((string) file_get_contents($this->path(self::PRIVATE_KEY))); + $encryptedKey = base64_decode((string) $bundle['k'], true); + $iv = base64_decode((string) $bundle['i'], true); + $payload = base64_decode((string) $bundle['d'], true); + $aesKey = ''; + + if ( + $privateKey === false + || $encryptedKey === false + || $iv === false + || $payload === false + || strlen($payload) <= self::GCM_TAG_LENGTH + || !openssl_private_decrypt($encryptedKey, $aesKey, $privateKey, OPENSSL_PKCS1_OAEP_PADDING) + ) { + return null; + } + + $tag = substr($payload, -self::GCM_TAG_LENGTH); + $ciphertext = substr($payload, 0, -self::GCM_TAG_LENGTH); + $plain = openssl_decrypt($ciphertext, 'aes-256-gcm', $aesKey, OPENSSL_RAW_DATA, $iv, $tag); + + return is_string($plain) ? $this->parsePayload($plain) : null; + } + + /** + * @return array{username: string, password: string}|null + */ + private function decryptLegacyRsaPayload(string $cipherTextBase64): ?array + { + $privateKey = openssl_pkey_get_private((string) file_get_contents($this->path(self::PRIVATE_KEY))); + $cipher = base64_decode($cipherTextBase64, true); + $plain = ''; + + if ($privateKey === false || $cipher === false) { + return null; + } + + if (!openssl_private_decrypt($cipher, $plain, $privateKey, OPENSSL_PKCS1_OAEP_PADDING)) { + return null; + } + + return $this->parsePayload($plain); + } + + /** + * @return array{username: string, password: string}|null + */ + private function parsePayload(string $json): ?array + { + $data = json_decode($json, true); + if (!is_array($data) || !isset($data['u'], $data['p'], $data['t'])) { + return null; + } + + if (abs(time() - (int) $data['t']) > (int) config('auth.login_encrypt_ttl', 300)) { + return null; + } + + return [ + 'username' => (string) $data['u'], + 'password' => (string) $data['p'], + ]; + } + + private function path(string $file): string + { + return storage_path(self::KEYS_DIR . '/' . $file); + } +} diff --git a/config/auth.php b/config/auth.php index e7811a250d..3cf4d6d16f 100644 --- a/config/auth.php +++ b/config/auth.php @@ -111,6 +111,10 @@ 'log_auth_events' => env('LOG_AUTH_EVENTS', true), + 'login_encrypt_credentials' => env('LOGIN_ENCRYPT_CREDENTIALS', true), + + 'login_encrypt_ttl' => env('LOGIN_ENCRYPT_TTL', 300), + /* |-------------------------------------------------------------------------- | Password Confirmation Timeout diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 41fb646f3e..aa47a2461e 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -14,7 +14,8 @@
@if (! $block) -
+ + @csrf @if (session()->has('timeout'))
{{ __("Your account has been timed out for security.") }}
@endif @@ -82,6 +83,7 @@ @endsection @section('js') + @include('auth.partials.login-credential-encryption') -@include('auth.partials.auth-language-scripts') + + @include('auth.partials.auth-language-scripts') + @include('auth.partials.login-credential-encryption') + diff --git a/resources/views/auth/partials/login-credential-encryption.blade.php b/resources/views/auth/partials/login-credential-encryption.blade.php new file mode 100644 index 0000000000..984fb9bb46 --- /dev/null +++ b/resources/views/auth/partials/login-credential-encryption.blade.php @@ -0,0 +1,92 @@ +@if (!empty($loginPublicKey)) + +@endif diff --git a/tests/Feature/LoginEncryptionTest.php b/tests/Feature/LoginEncryptionTest.php new file mode 100644 index 0000000000..a424e1ff53 --- /dev/null +++ b/tests/Feature/LoginEncryptionTest.php @@ -0,0 +1,72 @@ +setUpLoginEncryptionKeys(); + } + + protected function tearDown(): void + { + $this->tearDownLoginEncryptionKeys(); + parent::tearDown(); + } + + public function test_login_with_encrypted_credentials(): void + { + User::factory()->create([ + 'username' => 'encrypted-user', + 'password' => Hash::make('encrypted-password'), + 'status' => 'ACTIVE', + ]); + + $response = $this->post('login', [ + 'encrypted' => 1, + 'encrypted_credentials' => $this->loginEncryption->encryptCredentials('encrypted-user', 'encrypted-password'), + ]); + + $response->assertRedirect('/'); + $response->assertSessionHasNoErrors(); + $this->assertAuthenticated(); + } + + public function test_login_rejects_invalid_encrypted_credentials(): void + { + User::factory()->create([ + 'username' => 'encrypted-user', + 'password' => Hash::make('encrypted-password'), + 'status' => 'ACTIVE', + ]); + + $response = $this->post('login', [ + 'encrypted' => 1, + 'encrypted_credentials' => 'invalid-cipher-text', + ]); + + $response->assertRedirect('/'); + $response->assertSessionHasErrors('username'); + $this->assertGuest(); + } + + public function test_login_page_exposes_public_key_when_encryption_is_enabled(): void + { + $response = $this->get(route('login')); + + $response->assertOk(); + $response->assertSee('BEGIN PUBLIC KEY', false); + $response->assertSee('login-form', false); + } +} diff --git a/tests/Support/InteractsWithLoginEncryptionKeys.php b/tests/Support/InteractsWithLoginEncryptionKeys.php new file mode 100644 index 0000000000..aeab4d32bd --- /dev/null +++ b/tests/Support/InteractsWithLoginEncryptionKeys.php @@ -0,0 +1,29 @@ + true, + 'auth.login_encrypt_ttl' => 300, + ]); + + $this->loginEncryption = app(LoginCredentialEncryption::class); + $this->loginEncryption->generateKeyPair(); + } + + protected function tearDownLoginEncryptionKeys(): void + { + @unlink(storage_path('app/keys/login_private.pem')); + @unlink(storage_path('app/keys/login_public.pem')); + } +} diff --git a/tests/unit/ProcessMaker/Services/LoginCredentialEncryptionTest.php b/tests/unit/ProcessMaker/Services/LoginCredentialEncryptionTest.php new file mode 100644 index 0000000000..efd5405600 --- /dev/null +++ b/tests/unit/ProcessMaker/Services/LoginCredentialEncryptionTest.php @@ -0,0 +1,60 @@ +setUpLoginEncryptionKeys(); + } + + protected function tearDown(): void + { + $this->tearDownLoginEncryptionKeys(); + parent::tearDown(); + } + + public function test_it_encrypts_and_decrypts_login_credentials(): void + { + $cipher = $this->loginEncryption->encryptCredentials('test-user', 'secret-password'); + $credentials = $this->loginEncryption->decryptCredentials($cipher); + + $this->assertSame('test-user', $credentials['username']); + $this->assertSame('secret-password', $credentials['password']); + } + + public function test_it_rejects_expired_credentials(): void + { + $cipher = $this->loginEncryption->encryptCredentials('test-user', 'secret-password', time() - 301); + + $this->assertNull($this->loginEncryption->decryptCredentials($cipher)); + } + + public function test_it_rejects_invalid_cipher_text(): void + { + $this->assertNull($this->loginEncryption->decryptCredentials('not-valid-base64-cipher')); + } + + public function test_it_encrypts_long_credentials_with_hybrid_encryption(): void + { + $cipher = $this->loginEncryption->encryptCredentials( + str_repeat('u', 255), + str_repeat('p', 200) + ); + + $credentials = $this->loginEncryption->decryptCredentials($cipher); + + $this->assertSame(str_repeat('u', 255), $credentials['username']); + $this->assertSame(str_repeat('p', 200), $credentials['password']); + } +}