From 0b18ae50e7f81d3cb282dbc5d4bd56a09235f544 Mon Sep 17 00:00:00 2001 From: Lewis Larsen Date: Fri, 23 Aug 2024 09:18:26 +0100 Subject: [PATCH] feat: Add bitbucket connection --- .env.example | 4 + .../Connections/BitbucketController.php | 59 +++++++++ .../Connections/GitHubController.php | 4 +- .../Connections/GitLabController.php | 4 +- app/Livewire/Profile/ConnectionsPage.php | 1 + app/Models/UserConnection.php | 13 ++ composer.json | 1 + composer.lock | 117 +++++++++++++++++- config/services.php | 6 + database/factories/UserConnectionFactory.php | 11 ++ .../components/icons/bitbucket.blade.php | 4 + .../views/livewire/pages/auth/login.blade.php | 17 +++ .../profile/connections-page.blade.php | 47 +++++++ routes/auth.php | 5 + tests/Feature/Connections/BitbucketTest.php | 103 +++++++++++++++ tests/Unit/Models/UserConnectionTest.php | 12 ++ 16 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 app/Http/Controllers/Connections/BitbucketController.php create mode 100644 resources/views/components/icons/bitbucket.blade.php create mode 100644 tests/Feature/Connections/BitbucketTest.php diff --git a/.env.example b/.env.example index d6f01536..5f0e1466 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,10 @@ GITHUB_CLIENT_SECRET= GITLAB_CLIENT_ID= GITLAB_CLIENT_SECRET= +### BITBUCKET AUTH ### +BITBUCKET_CLIENT_ID= +BITBUCKET_CLIENT_SECRET= + ### DEVICE ENDPOINT ### ENABLE_DEVICE_AUTH_ENDPOINT=false diff --git a/app/Http/Controllers/Connections/BitbucketController.php b/app/Http/Controllers/Connections/BitbucketController.php new file mode 100644 index 00000000..380ba621 --- /dev/null +++ b/app/Http/Controllers/Connections/BitbucketController.php @@ -0,0 +1,59 @@ +redirectToProvider(self::PROVIDER_NAME); + } + + /** + * Handle the callback from Bitbucket. + */ + public function callback(): RedirectResponse + { + try { + return $this->handleProviderCallback(self::PROVIDER_NAME); + } catch (InvalidStateException) { + return $this->handleInvalidState(); + } catch (Exception $e) { + return $this->handleGenericError($e); + } + } + + /** + * Handle invalid state exception, which could occur if the user cancels the process. + */ + protected function handleInvalidState(): RedirectResponse + { + return redirect()->route('login') + ->with('loginError', 'Bitbucket connection was cancelled or invalid. Please try again if you want to connect your account.'); + } + + /** + * Handle generic errors during the Bitbucket connection process. + */ + protected function handleGenericError(Exception $exception): RedirectResponse + { + logger()->error('Bitbucket connection error', ['error' => $exception->getMessage()]); + + return redirect()->route('login') + ->with('loginError', 'An error occurred while connecting to Bitbucket. Please try again later.'); + } +} diff --git a/app/Http/Controllers/Connections/GitHubController.php b/app/Http/Controllers/Connections/GitHubController.php index 2e0fda97..afdfff02 100644 --- a/app/Http/Controllers/Connections/GitHubController.php +++ b/app/Http/Controllers/Connections/GitHubController.php @@ -39,7 +39,7 @@ public function callback(): RedirectResponse protected function handleInvalidState(): RedirectResponse { return redirect()->route('login') - ->with('info', 'GitHub connection was cancelled or invalid. Please try again if you want to connect your account.'); + ->with('loginError', 'GitHub connection was cancelled or invalid. Please try again if you want to connect your account.'); } /** @@ -51,6 +51,6 @@ protected function handleGenericError(Exception $exception): RedirectResponse logger()->error('GitHub connection error', ['error' => $exception->getMessage()]); return redirect()->route('login') - ->with('error', 'An error occurred while connecting to GitHub. Please try again later.'); + ->with('loginError', 'An error occurred while connecting to GitHub. Please try again later.'); } } diff --git a/app/Http/Controllers/Connections/GitLabController.php b/app/Http/Controllers/Connections/GitLabController.php index 8984eb5e..dfc2c3c7 100644 --- a/app/Http/Controllers/Connections/GitLabController.php +++ b/app/Http/Controllers/Connections/GitLabController.php @@ -39,7 +39,7 @@ public function callback(): RedirectResponse protected function handleInvalidState(): RedirectResponse { return redirect()->route('login') - ->with('info', 'GitLab connection was cancelled or invalid. Please try again if you want to connect your account.'); + ->with('loginError', 'GitLab connection was cancelled or invalid. Please try again if you want to connect your account.'); } /** @@ -51,6 +51,6 @@ protected function handleGenericError(Exception $exception): RedirectResponse logger()->error('GitLab connection error', ['error' => $exception->getMessage()]); return redirect()->route('login') - ->with('error', 'An error occurred while connecting to GitLab. Please try again later.'); + ->with('loginError', 'An error occurred while connecting to GitLab. Please try again later.'); } } diff --git a/app/Livewire/Profile/ConnectionsPage.php b/app/Livewire/Profile/ConnectionsPage.php index 956696df..409f5649 100644 --- a/app/Livewire/Profile/ConnectionsPage.php +++ b/app/Livewire/Profile/ConnectionsPage.php @@ -45,6 +45,7 @@ public function connect(string $provider): void $route = match ($provider) { 'github' => 'github.redirect', 'gitlab' => 'gitlab.redirect', + 'bitbucket' => 'bitbucket.redirect', default => null, }; diff --git a/app/Models/UserConnection.php b/app/Models/UserConnection.php index d564167b..271e7c40 100644 --- a/app/Models/UserConnection.php +++ b/app/Models/UserConnection.php @@ -30,6 +30,11 @@ class UserConnection extends Model */ public const string PROVIDER_GITLAB = 'gitlab'; + /** + * Provider name for Bitbucket connections. + */ + public const string PROVIDER_BITBUCKET = 'bitbucket'; + /** * The attributes that aren't mass assignable. * @@ -63,6 +68,14 @@ public function isGitLab(): bool return $this->provider_name === self::PROVIDER_GITLAB; } + /** + * Determine if the connection is for GitLab. + */ + public function isBitbucket(): bool + { + return $this->provider_name === self::PROVIDER_BITBUCKET; + } + /** * The attributes that should be cast. * diff --git a/composer.json b/composer.json index cf149c82..ace30142 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "livewire/volt": "^1.0", "masmerise/livewire-toaster": "^2.2", "phpseclib/phpseclib": "~3.0", + "socialiteproviders/bitbucket": "^4.1", "spatie/laravel-flare": "^1.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index a949314f..caa8555f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e48cb82a273dc20d5eb61da3c232f36f", + "content-hash": "a43dbeed5132f6d849891006d052a382", "packages": [ { "name": "aws/aws-crt-php", @@ -6304,6 +6304,121 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "socialiteproviders/bitbucket", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Bitbucket.git", + "reference": "b0c945a4ebc0fe29166650bbf5cfae5c4aaefc2a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Bitbucket/zipball/b0c945a4ebc0fe29166650bbf5cfae5c4aaefc2a", + "reference": "b0c945a4ebc0fe29166650bbf5cfae5c4aaefc2a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.2 || ^8.0", + "socialiteproviders/manager": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Bitbucket\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Anton Komarev", + "email": "ell@cybercog.su" + } + ], + "description": "Bitbucket OAuth2 Provider for Laravel Socialite", + "support": { + "source": "https://github.com/SocialiteProviders/Bitbucket/tree/4.1.0" + }, + "time": "2020-12-01T23:10:59+00:00" + }, + { + "name": "socialiteproviders/manager", + "version": "v4.6.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Manager.git", + "reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/dea5190981c31b89e52259da9ab1ca4e2b258b21", + "reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21", + "shasum": "" + }, + "require": { + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0", + "laravel/socialite": "^5.5", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "SocialiteProviders\\Manager\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "SocialiteProviders\\Manager\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andy Wendt", + "email": "andy@awendt.com" + }, + { + "name": "Anton Komarev", + "email": "a.komarev@cybercog.su" + }, + { + "name": "Miguel Piedrafita", + "email": "soy@miguelpiedrafita.com" + }, + { + "name": "atymic", + "email": "atymicq@gmail.com", + "homepage": "https://atymic.dev" + } + ], + "description": "Easily add new or override built-in providers in Laravel Socialite.", + "homepage": "https://socialiteproviders.com", + "keywords": [ + "laravel", + "manager", + "oauth", + "providers", + "socialite" + ], + "support": { + "issues": "https://github.com/socialiteproviders/manager/issues", + "source": "https://github.com/socialiteproviders/manager" + }, + "time": "2024-05-04T07:57:39+00:00" + }, { "name": "spatie/backtrace", "version": "1.6.2", diff --git a/config/services.php b/config/services.php index b56b33f8..318bd299 100644 --- a/config/services.php +++ b/config/services.php @@ -38,4 +38,10 @@ 'client_secret' => env('GITLAB_CLIENT_SECRET'), 'redirect' => config('app.url') . '/auth/gitlab/callback', ], + + 'bitbucket' => [ + 'client_id' => env('BITBUCKET_CLIENT_ID'), + 'client_secret' => env('BITBUCKET_CLIENT_SECRET'), + 'redirect' => config('app.url') . '/auth/bitbucket/callback', + ], ]; diff --git a/database/factories/UserConnectionFactory.php b/database/factories/UserConnectionFactory.php index 42fe764d..70c90026 100644 --- a/database/factories/UserConnectionFactory.php +++ b/database/factories/UserConnectionFactory.php @@ -26,6 +26,7 @@ public function definition(): array 'provider_name' => $this->faker->randomElement([ UserConnection::PROVIDER_GITHUB, UserConnection::PROVIDER_GITLAB, + UserConnection::PROVIDER_BITBUCKET, ]), 'provider_user_id' => $this->faker->uuid, 'provider_email' => $this->faker->safeEmail, @@ -55,4 +56,14 @@ public function gitlab(): self 'provider_name' => UserConnection::PROVIDER_GITLAB, ]); } + + /** + * Indicate that the connection is for Bitbucket. + */ + public function bitbucket(): self + { + return $this->state(fn (array $attributes) => [ + 'provider_name' => UserConnection::PROVIDER_BITBUCKET, + ]); + } } diff --git a/resources/views/components/icons/bitbucket.blade.php b/resources/views/components/icons/bitbucket.blade.php new file mode 100644 index 00000000..a5ab96ef --- /dev/null +++ b/resources/views/components/icons/bitbucket.blade.php @@ -0,0 +1,4 @@ +merge() }}>Bitbucket + + diff --git a/resources/views/livewire/pages/auth/login.blade.php b/resources/views/livewire/pages/auth/login.blade.php index 3c90511f..9c89fa4d 100644 --- a/resources/views/livewire/pages/auth/login.blade.php +++ b/resources/views/livewire/pages/auth/login.blade.php @@ -116,6 +116,23 @@ public function login(): void @endif + @if (config('services.bitbucket.client_id') && config('services.bitbucket.client_secret')) +
+
+
+
+
+
+ + +
+ @endif +
{{ __('By creating an account, you agree to our Terms of Service and our Privacy Policy.') }} diff --git a/resources/views/livewire/profile/connections-page.blade.php b/resources/views/livewire/profile/connections-page.blade.php index 05006d73..9508108c 100644 --- a/resources/views/livewire/profile/connections-page.blade.php +++ b/resources/views/livewire/profile/connections-page.blade.php @@ -106,6 +106,53 @@ class="w-full sm:w-auto justify-center"
@endif + @if (config('services.bitbucket.client_id') && config('services.bitbucket.client_secret')) + +
+
+
+
+
+ Bitbucket +
+
+

{{ __('Bitbucket') }}

+

{{ __('Attach your Bitbucket account to unlock additional features.') }}

+
+
+
+ @if ($this->isConnected('bitbucket')) + @if ($this->hasRefreshToken('bitbucket')) + + {{ __('Refresh Token') }} + + @endif + + {{ __('Disconnect') }} + + @else + + {{ __('Connect') }} + + @endif +
+
+
+
+ @endif +
diff --git a/routes/auth.php b/routes/auth.php index 2f407622..a67b54c9 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Auth\TwoFactorRequiredController; use App\Http\Controllers\Auth\VerifyEmailController; +use App\Http\Controllers\Connections\BitbucketController; use App\Http\Controllers\Connections\GitHubController; use App\Http\Controllers\Connections\GitLabController; use Illuminate\Support\Facades\Route; @@ -22,6 +23,10 @@ Route::get('auth/gitlab', [GitLabController::class, 'redirect'])->name('gitlab.redirect'); Route::get('auth/gitlab/callback', [GitLabController::class, 'callback'])->name('gitlab.callback'); +// Bitbucket Routes +Route::get('auth/bitbucket', [BitbucketController::class, 'redirect'])->name('bitbucket.redirect'); +Route::get('auth/bitbucket/callback', [BitbucketController::class, 'callback'])->name('bitbucket.callback'); + Route::middleware('auth')->group(function () { Volt::route('verify-email', 'pages.auth.verify-email')->name('verification.notice'); diff --git a/tests/Feature/Connections/BitbucketTest.php b/tests/Feature/Connections/BitbucketTest.php new file mode 100644 index 00000000..a9c815cc --- /dev/null +++ b/tests/Feature/Connections/BitbucketTest.php @@ -0,0 +1,103 @@ +socialiteMock = Mockery::mock('alias:' . Socialite::class); +}); + +afterEach(function (): void { + Mockery::close(); +}); + +it('redirects to Bitbucket', function (): void { + $this->socialiteMock->shouldReceive('driver->redirect') + ->once() + ->andReturn(redirect('https://bitbucket.org/site/oauth2/authorize')); + + $response = $this->get(route('bitbucket.redirect')); + + $response->assertRedirect('https://bitbucket.org/site/oauth2/authorize'); +}); + +it('handles Bitbucket callback for new user', function (): void { + $mock = Mockery::mock(SocialiteUser::class); + $mock->shouldReceive([ + 'getId' => '123456', + 'getEmail' => 'test@example.com', + 'getName' => 'Test User', + 'token' => 'mock-access-token', + 'refreshToken' => 'mock-refresh-token', + ]); + + $this->socialiteMock->shouldReceive('driver->user') + ->once() + ->andReturn($mock); + + $response = $this->get(route('bitbucket.callback')); + + $response->assertRedirect(route('overview')); + $this->assertDatabaseHas('users', ['email' => 'test@example.com']); + $this->assertDatabaseHas('user_connections', [ + 'provider_name' => UserConnection::PROVIDER_BITBUCKET, + 'provider_user_id' => '123456', + ]); + expect(Auth::check())->toBeTrue(); +}); + +it('handles Bitbucket callback for existing user', function (): void { + $user = User::factory()->create(['email' => 'existing@example.com']); + + $mock = Mockery::mock(SocialiteUser::class); + $mock->shouldReceive([ + 'getId' => '789012', + 'getEmail' => 'existing@example.com', + 'getName' => 'Existing User', + 'token' => 'mock-access-token', + 'refreshToken' => 'mock-refresh-token', + ]); + + $this->socialiteMock->shouldReceive('driver->user') + ->once() + ->andReturn($mock); + + $response = $this->get(route('bitbucket.callback')); + + $response->assertRedirect(route('overview')); + $this->assertDatabaseHas('user_connections', [ + 'user_id' => $user->id, + 'provider_name' => UserConnection::PROVIDER_BITBUCKET, + 'provider_user_id' => '789012', + ]); + expect(Auth::id())->toBe($user->id); +}); + +it('handles Bitbucket callback for already linked account', function (): void { + $user = User::factory()->create(); + UserConnection::factory()->create([ + 'user_id' => $user->id, + 'provider_name' => UserConnection::PROVIDER_BITBUCKET, + 'provider_user_id' => '123456', + ]); + + $mock = Mockery::mock(SocialiteUser::class); + $mock->shouldReceive([ + 'getId' => '123456', + 'getEmail' => $user->email, + ]); + + $this->socialiteMock->shouldReceive('driver->user') + ->once() + ->andReturn($mock); + + $response = $this->get(route('bitbucket.callback')); + + $response->assertRedirect(route('overview')); + expect(Auth::id())->toBe($user->id); +}); diff --git a/tests/Unit/Models/UserConnectionTest.php b/tests/Unit/Models/UserConnectionTest.php index c3ff230d..73508bff 100644 --- a/tests/Unit/Models/UserConnectionTest.php +++ b/tests/Unit/Models/UserConnectionTest.php @@ -27,3 +27,15 @@ $this->assertFalse($userProvider->isGitLab()); }); + +test('it returns true if the provider is bitbucket', function (): void { + $userProvider = UserConnection::factory()->bitbucket()->create(); + + $this->assertTrue($userProvider->isBitbucket()); +}); + +test('it returns false if the provider is not bitbucket', function (): void { + $userProvider = UserConnection::factory()->github()->create(); + + $this->assertFalse($userProvider->isBitbucket()); +});