From 556551b4f3b8f750f1efeea6409b5a5880334b88 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Sun, 6 Jul 2025 22:51:45 +0200 Subject: [PATCH] Add SSH Keys to Profile (#1478) --- app/Filament/Pages/Auth/EditProfile.php | 117 +++++++++++++++++++++--- app/Services/Ssh/KeyCreationService.php | 48 ++++++++++ lang/en/profile.php | 12 ++- 3 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 app/Services/Ssh/KeyCreationService.php diff --git a/app/Filament/Pages/Auth/EditProfile.php b/app/Filament/Pages/Auth/EditProfile.php index b546c0e3e..f7ea4eef7 100644 --- a/app/Filament/Pages/Auth/EditProfile.php +++ b/app/Filament/Pages/Auth/EditProfile.php @@ -8,7 +8,9 @@ use App\Facades\Activity; use App\Models\ActivityLog; use App\Models\ApiKey; use App\Models\User; +use App\Models\UserSSHKey; use App\Services\Helpers\LanguageService; +use App\Services\Ssh\KeyCreationService; use App\Services\Users\ToggleTwoFactorService; use App\Services\Users\TwoFactorSetupService; use App\Services\Users\UserUpdateService; @@ -19,6 +21,7 @@ use chillerlan\QRCode\Common\Version; use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QROptions; use DateTimeZone; +use Exception; use Filament\Actions\Action as HeaderAction; use Filament\Actions\ActionGroup; use Filament\Forms\Components\Actions; @@ -276,7 +279,7 @@ class EditProfile extends BaseEditProfile ->icon('tabler-key') ->schema([ Grid::make('name')->columns(5)->schema([ - Section::make(trans('profile.create_key'))->columnSpan(3)->schema([ + Section::make(trans('profile.create_api_key'))->columnSpan(3)->schema([ TextInput::make('description') ->label(trans('profile.description')) ->live(), @@ -288,9 +291,9 @@ class EditProfile extends BaseEditProfile ->helperText(trans('profile.allowed_ips_help')) ->columnSpanFull(), ])->headerActions([ - Action::make('Create') + Action::make('create') ->label(trans('filament-actions::create.single.modal.actions.create.label')) - ->disabled(fn (Get $get) => $get('description') === null) + ->disabled(fn (Get $get) => empty($get('description'))) ->successRedirectUrl(self::getUrl(['tab' => '-api-keys-tab'], panel: 'app')) ->action(function (Get $get, Action $action, User $user) { $token = $user->createToken( @@ -306,7 +309,7 @@ class EditProfile extends BaseEditProfile ->log(); Notification::make() - ->title(trans('profile.key_created')) + ->title(trans('profile.api_key_created')) ->body($token->accessToken->identifier . $token->plainTextToken) ->persistent() ->success() @@ -315,17 +318,28 @@ class EditProfile extends BaseEditProfile $action->success(); }), ]), - Section::make(trans('profile.keys'))->label(trans('profile.keys'))->columnSpan(2)->schema([ - Repeater::make('keys') - ->label('') + Section::make(trans('profile.api_keys'))->columnSpan(2)->schema([ + Repeater::make('api_keys') + ->hiddenLabel() ->relationship('apiKeys') ->addable(false) ->itemLabel(fn ($state) => $state['identifier']) ->deleteAction(function (Action $action) { - $action->requiresConfirmation()->action(function (array $arguments, Repeater $component) { + $action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) { $items = $component->getState(); $key = $items[$arguments['item']]; - ApiKey::find($key['id'] ?? null)?->delete(); + + $apiKey = ApiKey::find($key['id'] ?? null); + if ($apiKey->exists()) { + $apiKey->delete(); + + Activity::event('user:api-key.delete') + ->actor($user) + ->subject($user) + ->subject($apiKey) + ->property('identifier', $apiKey->identifier) + ->log(); + } unset($items[$arguments['item']]); @@ -335,7 +349,8 @@ class EditProfile extends BaseEditProfile }); }) ->schema(fn () => [ - Placeholder::make('adf')->label(fn (ApiKey $key) => $key->memo), + Placeholder::make('memo') + ->label(fn (ApiKey $key) => $key->memo), ]), ]), ]), @@ -343,7 +358,87 @@ class EditProfile extends BaseEditProfile Tab::make(trans('profile.tabs.ssh_keys')) ->icon('tabler-lock-code') - ->hidden(), + ->schema([ + Grid::make('name')->columns(5)->schema([ + Section::make(trans('profile.create_ssh_key'))->columnSpan(3)->schema([ + TextInput::make('name') + ->label(trans('profile.name')) + ->live(), + Textarea::make('public_key') + ->label(trans('profile.public_key')) + ->autosize() + ->live(), + ])->headerActions([ + Action::make('create') + ->label(trans('filament-actions::create.single.modal.actions.create.label')) + ->disabled(fn (Get $get) => empty($get('name')) || empty($get('public_key'))) + ->successRedirectUrl(self::getUrl(['tab' => '-ssh-keys-tab'], panel: 'app')) + ->action(function (Get $get, Action $action, User $user, KeyCreationService $service) { + try { + $sshKey = $service->handle($user, $get('name'), $get('public_key')); + + Activity::event('user:ssh-key.create') + ->actor($user) + ->subject($user) + ->subject($sshKey) + ->property('fingerprint', $sshKey->fingerprint) + ->log(); + + Notification::make() + ->title(trans('profile.ssh_key_created')) + ->body("SHA256:{$sshKey->fingerprint}") + ->success() + ->send(); + + $action->success(); + } catch (Exception $exception) { + Notification::make() + ->title(trans('profile.could_not_create_ssh_key')) + ->body($exception->getMessage()) + ->danger() + ->send(); + + $action->failure(); + } + }), + ]), + Section::make(trans('profile.ssh_keys'))->columnSpan(2)->schema([ + Repeater::make('ssh_keys') + ->hiddenLabel() + ->relationship('sshKeys') + ->addable(false) + ->itemLabel(fn ($state) => $state['name']) + ->deleteAction(function (Action $action) { + $action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) { + $items = $component->getState(); + $key = $items[$arguments['item']]; + + $sshKey = UserSSHKey::find($key['id'] ?? null); + if ($sshKey->exists()) { + $sshKey->delete(); + + Activity::event('user:ssh-key.delete') + ->actor($user) + ->subject($user) + ->subject($sshKey) + ->property('fingerprint', $sshKey->fingerprint) + ->log(); + } + + unset($items[$arguments['item']]); + + $component->state($items); + + $component->callAfterStateUpdated(); + }); + }) + ->schema(fn () => [ + Placeholder::make('fingerprint') + ->label(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"), + ]), + ]), + ]), + ]), Tab::make(trans('profile.tabs.activity')) ->icon('tabler-history') diff --git a/app/Services/Ssh/KeyCreationService.php b/app/Services/Ssh/KeyCreationService.php new file mode 100644 index 000000000..4bd9f7faa --- /dev/null +++ b/app/Services/Ssh/KeyCreationService.php @@ -0,0 +1,48 @@ +getLength() < 2048) { + throw new Exception('RSA keys must be at least 2048 bytes in length'); + } + + $fingerprint = $key->getFingerprint('sha256'); + if ($user->sshKeys()->where('fingerprint', $fingerprint)->exists()) { + throw new Exception('The public key provided already exists on your account'); + } + + /** @var UserSSHKey $sshKey */ + $sshKey = $user->sshKeys()->create([ + 'name' => $name, + 'public_key' => $key->toString('PKCS8'), + 'fingerprint' => $fingerprint, + ]); + + return $sshKey; + } +} diff --git a/lang/en/profile.php b/lang/en/profile.php index 525f6f235..08eec394f 100644 --- a/lang/en/profile.php +++ b/lang/en/profile.php @@ -33,12 +33,18 @@ return [ 'backup_codes' => 'Backup Codes', 'disable_2fa' => 'Disable 2FA', 'disable_2fa_help' => 'Enter your current 2FA code to disable Two Factor Authentication', - 'keys' => 'Keys', - 'create_key' => 'Create API Key', - 'key_created' => 'Key Created', + 'api_keys' => 'API Keys', + 'create_api_key' => 'Create API Key', + 'api_key_created' => 'API Key Created', 'description' => 'Description', 'allowed_ips' => 'Allowed IPs', 'allowed_ips_help' => 'Press enter to add a new IP address or leave blank to allow any IP address', + 'ssh_keys' => 'SSH Keys', + 'create_ssh_key' => 'Create SSH Key', + 'ssh_key_created' => 'SSH Key Created', + 'name' => 'Name', + 'public_key' => 'Public Key', + 'could_not_create_ssh_key' => 'Could not create ssh key', 'dashboard' => 'Dashboard', 'dashboard_layout' => 'Dashboard Layout', 'console' => 'Console',