Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/public/js
/public/css
/storage/*.key
/storage/app/keys/
/storage/*.index
/storage/tenant_*
/vendor
Expand Down
29 changes: 29 additions & 0 deletions ProcessMaker/Console/Commands/GenerateLoginKeys.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace ProcessMaker\Console\Commands;

use Illuminate\Console\Command;
use ProcessMaker\Services\LoginCredentialEncryption;

class GenerateLoginKeys extends Command
{
protected $signature = 'login:generate-keys {--force : Overwrite existing key files}';

protected $description = 'Generate RSA key pair for encrypted login credentials';

public function handle(LoginCredentialEncryption $encryption): int
{
if ($encryption->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;
}
}
9 changes: 8 additions & 1 deletion ProcessMaker/Http/Controllers/Auth/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
230 changes: 230 additions & 0 deletions ProcessMaker/Services/LoginCredentialEncryption.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
<?php

declare(strict_types=1);

namespace ProcessMaker\Services;

use Illuminate\Http\Request;

class LoginCredentialEncryption
{
private const KEYS_DIR = 'app/keys';

private const PRIVATE_KEY = 'login_private.pem';

private const PUBLIC_KEY = 'login_public.pem';

private const HYBRID_VERSION = 2;

private const GCM_TAG_LENGTH = 16;

public function isEnabled(): bool
{
return (bool) config('auth.login_encrypt_credentials', true);
}

public function publicKeyForLogin(): ?string
{
if (!$this->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<string, mixed> $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);
}
}
4 changes: 4 additions & 0 deletions config/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion resources/views/auth/login.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
<div class="col-md-8 offset-md-2 col-lg-6 offset-lg-3">
<div class="card card-body p-3">
@if (! $block)
<form method="POST" class="form" action="{{ route('login') }}">
<form method="POST" class="form login-form" action="{{ route('login') }}">
@csrf
@if (session()->has('timeout'))
<div class="alert alert-danger">{{ __("Your account has been timed out for security.") }}</div>
@endif
Expand Down Expand Up @@ -82,6 +83,7 @@
@endsection

@section('js')
@include('auth.partials.login-credential-encryption')
<script>
const browser = navigator.userAgent;
const isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(browser);
Expand Down
12 changes: 7 additions & 5 deletions resources/views/auth/newLogin.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
@endcomponent
</div>
@if (! $block)
<form method="POST" class="form" action="{{ route('login') }}">
<form method="POST" class="form login-form" action="{{ route('login') }}">
@csrf
@if (session()->has('timeout'))
<div class="alert alert-danger">{{ __("Your account has been timed out for security.") }}</div>
@endif
Expand Down Expand Up @@ -139,8 +140,7 @@ class="login-remember-input"
@endif
</div>
</div>
</body>
<script>
<script>
const browser = navigator.userAgent;
const isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(browser);
document.cookie = "isMobile=false"
Expand Down Expand Up @@ -173,6 +173,8 @@ function updateCapsLockWarning(event) {
capsLockWarning.hidden = true;
});
}
</script>
@include('auth.partials.auth-language-scripts')
</script>
@include('auth.partials.auth-language-scripts')
@include('auth.partials.login-credential-encryption')
</body>
</html>
Loading
Loading