diff --git a/.env.example b/.env.example index 959655f..62e1ebc 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_NAME="Thế Giới Số" APP_ENV=local APP_KEY= APP_PREVIOUS_KEYS= +HASH_DRIVER=argon2id APP_DEBUG=false APP_URL=https://dev.tgs.com.vn APP_LOCALE=vi @@ -17,10 +18,9 @@ DB_DATABASE=portal_dev DB_USERNAME=portal_dev DB_PASSWORD= -#Config Custom SignRSA +#Legacy SignRSA (3DES) — chỉ dùng để đọc dữ liệu cũ, gỡ sau khi chạy secrets:reencrypt RSA_KEY= RSA_PUBLIC= -RSA_PRIVATE= RSA_PREVIOUS_KEY= RSA_PREVIOUS_PUBLIC= diff --git a/app/Console/Commands/ReencryptSecrets.php b/app/Console/Commands/ReencryptSecrets.php index dbef0fc..b7025e7 100644 --- a/app/Console/Commands/ReencryptSecrets.php +++ b/app/Console/Commands/ReencryptSecrets.php @@ -16,7 +16,7 @@ class ReencryptSecrets extends Command { protected $signature = 'secrets:reencrypt {--dry-run}'; - protected $description = 'Re-encrypt stored secrets with the current APP_KEY and RSA_KEY (old values are read via APP_PREVIOUS_KEYS / RSA_PREVIOUS_*)'; + protected $description = 'Re-encrypt stored secrets with the current APP_KEY (old values are read via APP_PREVIOUS_KEYS; legacy 3DES SignRSA values via RSA_KEY/RSA_PUBLIC and RSA_PREVIOUS_*)'; private const RSA_COLUMNS = [ ['module_vcenter', 'password'], diff --git a/app/Http/Controllers/Api/Admin/Auth/AuthController.php b/app/Http/Controllers/Api/Admin/Auth/AuthController.php index 5b96bab..e77e010 100644 --- a/app/Http/Controllers/Api/Admin/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Admin/Auth/AuthController.php @@ -55,6 +55,7 @@ class AuthController extends Controller RateLimiter::clear($throttleKey); $user = Auth::guard('admin')->user(); + Auth::guard('admin')->getProvider()->rehashPasswordIfRequired($user, $request->only('password')); $this->activityLogger->logAuth('login', 'admin', $user->id, $user->username ?? $user->email); diff --git a/app/Http/Services/SignRSA.php b/app/Http/Services/SignRSA.php index 8ed9cb3..c225dd0 100644 --- a/app/Http/Services/SignRSA.php +++ b/app/Http/Services/SignRSA.php @@ -2,64 +2,51 @@ namespace App\Http\Services; +use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Support\Facades\Crypt; + class SignRSA { - protected string $key; - protected ?string $private; - protected string $public; - protected ?string $previousKey; - protected ?string $previousPublic; + private const LEGACY_KEYS = [['rsa.key', 'rsa.public'], ['rsa.previous_key', 'rsa.previous_public']]; - public function __construct() + public function EncryptFunc($plaintext): string { - $this->key = base64_decode(config('rsa.key')); - $this->private = str_replace('\n', "\n", config('rsa.private')); - $this->public = str_replace('\n', "\n", config('rsa.public')); - $this->previousKey = config('rsa.previous_key') ? base64_decode(config('rsa.previous_key')) : null; - $this->previousPublic = config('rsa.previous_public') ? str_replace('\n', "\n", config('rsa.previous_public')) : null; + return Crypt::encryptString((string) $plaintext); } - - public function EncryptFunc($signdata): string - { - - $sign = $this->SignData($signdata); - - return $this->EncryptedData($signdata . '|' . $sign, $this->key); - } - - private function SignData($signdata = ''): string - { - - openssl_sign($signdata, $result, $this->private, 'sha256WithRSAEncryption'); - - return base64_encode($result); - } - - private function EncryptedData($original_schema = '', $key_hash = ''): string - { - $encrypted_data = openssl_encrypt($original_schema, 'DES-EDE3', $key_hash, OPENSSL_RAW_DATA); - - return base64_encode($encrypted_data); - } - - /* - * Function Decrypt Data - */ - public function DecryptFunc($data): string { if (!$data) { - return false; + return ''; } + + try { + return Crypt::decryptString($data); + } catch (DecryptException) { + return $this->decryptLegacy((string) $data); + } + } + + private function decryptLegacy(string $data): string + { $encrypted_data = base64_decode($data); - $verified = $this->Verify_Sign((string) $this->DecryptData($encrypted_data, $this->key), $this->public); - if ($verified === false && $this->previousKey && $this->previousPublic) { - $verified = $this->Verify_Sign((string) $this->DecryptData($encrypted_data, $this->previousKey), $this->previousPublic); + foreach (self::LEGACY_KEYS as [$keyConfig, $publicConfig]) { + if (!config($keyConfig) || !config($publicConfig)) { + continue; + } + + $verified = $this->Verify_Sign( + (string) $this->DecryptData($encrypted_data, base64_decode(config($keyConfig))), + str_replace('\n', "\n", config($publicConfig)), + ); + + if ($verified !== false) { + return $verified; + } } - return $verified; + return ''; } private function DecryptData($encrypted_data, $random_key): bool|string diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index 31354f9..006a90b 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -72,6 +72,8 @@ class ClientAuthService ]); } + $guard->getProvider()->rehashPasswordIfRequired($client, $credentials); + return $client; } @@ -98,7 +100,7 @@ class ClientAuthService 'fullname' => $data['fullname'], 'phone' => $data['phone'] ?? null, 'email' => $data['email'], - 'password' => bcrypt($data['password']), + 'password' => Hash::make($data['password']), 'status' => 0, 'email_verify_code' => $code, 'email_verify_code_expired_at' => now()->addMinutes(5), diff --git a/app/Services/Imports/LegacyPortalImporter.php b/app/Services/Imports/LegacyPortalImporter.php index 931d81d..c40e78d 100644 --- a/app/Services/Imports/LegacyPortalImporter.php +++ b/app/Services/Imports/LegacyPortalImporter.php @@ -2,6 +2,7 @@ namespace App\Services\Imports; +use App\Http\Facade\SignRSA; use App\Models\Customer; use App\Models\Service; use Illuminate\Support\Facades\DB; @@ -163,7 +164,7 @@ class LegacyPortalImporter 'ip' => $row->vmware_ip, 'port' => is_numeric($row->vmware_port) ? (int) $row->vmware_port : null, 'user' => $row->vmware_user, - 'password' => $row->vmware_password, + 'password' => $row->vmware_password ? SignRSA::EncryptFunc($row->vmware_password) : null, 'hostname' => $row->vmware_hostname, 'vmware_id' => $vcVmId, 'virtual_machine_id' => $vmInventoryId, diff --git a/config/hashing.php b/config/hashing.php index bcd3be4..936d4c9 100644 --- a/config/hashing.php +++ b/config/hashing.php @@ -15,7 +15,7 @@ return [ | */ - 'driver' => 'bcrypt', + 'driver' => env('HASH_DRIVER', 'bcrypt'), /* |-------------------------------------------------------------------------- diff --git a/config/rsa.php b/config/rsa.php index 7b3930b..aade8a5 100644 --- a/config/rsa.php +++ b/config/rsa.php @@ -3,7 +3,6 @@ return [ 'key' => env('RSA_KEY'), 'public' => env('RSA_PUBLIC'), - 'private' => env('RSA_PRIVATE'), 'previous_key' => env('RSA_PREVIOUS_KEY'), 'previous_public' => env('RSA_PREVIOUS_PUBLIC'), ]; diff --git a/database/migrations/2026_10_15_000009_encrypt_legacy_module_vmware_passwords.php b/database/migrations/2026_10_15_000009_encrypt_legacy_module_vmware_passwords.php new file mode 100644 index 0000000..b8f764b --- /dev/null +++ b/database/migrations/2026_10_15_000009_encrypt_legacy_module_vmware_passwords.php @@ -0,0 +1,29 @@ +select(['id', 'password']) + ->whereNotNull('password') + ->where('password', '!=', '') + ->chunkById(200, function ($rows) { + foreach ($rows as $row) { + if (SignRSA::DecryptFunc($row->password) !== '') { + continue; + } + + DB::table('module_vmware')->where('id', $row->id)->update(['password' => SignRSA::EncryptFunc($row->password)]); + } + }); + } + + public function down(): void + { + } +}; diff --git a/tests/Feature/Auth/PasswordRehashTest.php b/tests/Feature/Auth/PasswordRehashTest.php new file mode 100644 index 0000000..5a3434c --- /dev/null +++ b/tests/Feature/Auth/PasswordRehashTest.php @@ -0,0 +1,68 @@ + 'argon2id']); + } + + public function test_customer_bcrypt_hash_is_upgraded_to_argon2id_on_login(): void + { + $customer = Customer::factory()->create([ + 'status' => 1, + 'password' => Hash::driver('bcrypt')->make(self::PASSWORD), + ]); + + $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->postJson('/v1/client/login', ['email' => $customer->email, 'password' => self::PASSWORD]) + ->assertOk() + ->assertJsonStructure(['data' => ['token']]); + + $hash = $customer->fresh()->password; + $this->assertStringStartsWith('$argon2id$', $hash); + $this->assertTrue(Hash::check(self::PASSWORD, $hash)); + } + + public function test_admin_bcrypt_hash_is_upgraded_to_argon2id_on_login(): void + { + $admin = User::factory()->create([ + 'username' => 'rehash-admin', + 'email' => 'rehash-admin@example.com', + 'password' => Hash::driver('bcrypt')->make(self::PASSWORD), + ]); + + $this->postJson('/api/v1/administrator/login', ['username' => 'rehash-admin', 'password' => self::PASSWORD]) + ->assertOk(); + + $this->assertStringStartsWith('$argon2id$', $admin->fresh()->password); + } + + public function test_wrong_password_does_not_rehash(): void + { + $bcrypt = Hash::driver('bcrypt')->make(self::PASSWORD); + $customer = Customer::factory()->create(['status' => 1, 'password' => $bcrypt]); + + $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->postJson('/v1/client/login', ['email' => $customer->email, 'password' => 'wrong-password']) + ->assertStatus(422); + + $this->assertSame($bcrypt, $customer->fresh()->password); + } +} diff --git a/tests/Feature/Console/ReencryptSecretsTest.php b/tests/Feature/Console/ReencryptSecretsTest.php index 3c0f139..4df961c 100644 --- a/tests/Feature/Console/ReencryptSecretsTest.php +++ b/tests/Feature/Console/ReencryptSecretsTest.php @@ -20,23 +20,29 @@ class ReencryptSecretsTest extends TestCase DB::table('infra_management')->delete(); } - public function test_rotating_keys_re_encrypts_rsa_and_app_key_columns(): void + public function test_legacy_3des_values_and_rotated_app_key_are_re_encrypted_with_current_app_key(): void { - [$oldRsa, $newRsa] = [$this->rsaConfig(), $this->rsaConfig()]; + $legacyRsa = $this->rsaConfig(); [$oldAppKey, $newAppKey] = [$this->appKey(), $this->appKey()]; - $this->useKeys($oldRsa, $oldAppKey); - $vcenterId = DB::table('module_vcenter')->insertGetId(['password' => SignRSA::EncryptFunc('vc-secret')]); + $this->useKeys($oldAppKey, legacyRsa: $legacyRsa); + $vcenterId = DB::table('module_vcenter')->insertGetId(['password' => $this->legacyEncrypt('vc-secret', $legacyRsa)]); + $infraId = DB::table('module_hosting')->insertGetId(['password' => SignRSA::EncryptFunc('hosting-secret')]); $dnsServerId = DB::table('dns_manager_servers')->insertGetId([ 'name' => 'ns1', 'ip_address' => '10.0.0.1', 'username' => 'admin', 'password' => Crypt::encryptString('plesk-secret'), ]); - DB::table('setting')->insert(['key' => 'smtp', 'value' => json_encode(['host' => 'smtp', 'password' => SignRSA::EncryptFunc('smtp-secret')])]); + DB::table('setting')->insert(['key' => 'smtp', 'value' => json_encode(['host' => 'smtp', 'password' => $this->legacyEncrypt('smtp-secret', $legacyRsa)])]); - $this->useKeys($newRsa, $newAppKey, previousRsa: $oldRsa, previousAppKey: $oldAppKey); + $this->assertSame('vc-secret', SignRSA::DecryptFunc(DB::table('module_vcenter')->where('id', $vcenterId)->value('password'))); + + $this->useKeys($newAppKey, previousAppKey: $oldAppKey, legacyRsa: $legacyRsa); $this->artisan('secrets:reencrypt')->assertSuccessful(); - $this->useKeys($newRsa, $newAppKey); - $this->assertSame('vc-secret', SignRSA::DecryptFunc(DB::table('module_vcenter')->where('id', $vcenterId)->value('password'))); + $this->useKeys($newAppKey); + $vcenterPassword = DB::table('module_vcenter')->where('id', $vcenterId)->value('password'); + $this->assertSame('vc-secret', Crypt::decryptString($vcenterPassword)); + $this->assertSame('vc-secret', SignRSA::DecryptFunc($vcenterPassword)); + $this->assertSame('hosting-secret', SignRSA::DecryptFunc(DB::table('module_hosting')->where('id', $infraId)->value('password'))); $this->assertSame('plesk-secret', Crypt::decryptString(DB::table('dns_manager_servers')->where('id', $dnsServerId)->value('password'))); $smtp = json_decode(DB::table('setting')->where('key', 'smtp')->value('value'), true); $this->assertSame('smtp-secret', SignRSA::DecryptFunc($smtp['password'])); @@ -45,34 +51,38 @@ class ReencryptSecretsTest extends TestCase public function test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command(): void { - $this->useKeys($this->rsaConfig(), $this->appKey()); + $this->useKeys($this->appKey()); $id = DB::table('module_vcenter')->insertGetId(['password' => SignRSA::EncryptFunc('vc-secret')]); $original = DB::table('module_vcenter')->where('id', $id)->value('password'); $this->artisan('secrets:reencrypt --dry-run')->assertSuccessful(); $this->assertSame($original, DB::table('module_vcenter')->where('id', $id)->value('password')); - $this->useKeys($this->rsaConfig(), $this->appKey()); + $this->useKeys($this->appKey(), legacyRsa: $this->rsaConfig()); $this->artisan('secrets:reencrypt')->assertFailed(); $this->assertSame($original, DB::table('module_vcenter')->where('id', $id)->value('password')); } - private function useKeys(array $rsa, string $appKey, ?array $previousRsa = null, ?string $previousAppKey = null): void + private function useKeys(string $appKey, ?string $previousAppKey = null, ?array $legacyRsa = null): void { config([ - 'rsa.key' => $rsa['key'], - 'rsa.public' => $rsa['public'], - 'rsa.private' => $rsa['private'], - 'rsa.previous_key' => $previousRsa['key'] ?? null, - 'rsa.previous_public' => $previousRsa['public'] ?? null, + 'rsa.key' => $legacyRsa['key'] ?? null, + 'rsa.public' => $legacyRsa['public'] ?? null, + 'rsa.previous_key' => null, + 'rsa.previous_public' => null, 'app.key' => $appKey, 'app.previous_keys' => $previousAppKey ? [$previousAppKey] : [], ]); $this->app->forgetInstance('encrypter'); - $this->app->forgetInstance(\App\Http\Services\SignRSA::class); Crypt::clearResolvedInstance('encrypter'); - SignRSA::clearResolvedInstance(\App\Http\Services\SignRSA::class); + } + + private function legacyEncrypt(string $plaintext, array $rsa): string + { + openssl_sign($plaintext, $signature, $rsa['private'], 'sha256WithRSAEncryption'); + + return base64_encode(openssl_encrypt($plaintext . '|' . base64_encode($signature), 'DES-EDE3', base64_decode($rsa['key']), OPENSSL_RAW_DATA)); } private function rsaConfig(): array