diff --git a/.env.example b/.env.example index ae67487f..d0ab8297 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,10 @@ REVERB_SERVER_PORT=8080 GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= +### GITLAB AUTH ### +GITLAB_CLIENT_ID= +GITLAB_CLIENT_SECRET= + ### DEVICE ENDPOINT ### ENABLE_DEVICE_AUTH_ENDPOINT=false diff --git a/app/Http/Controllers/Auth/GitLabSocialiteController.php b/app/Http/Controllers/Auth/GitLabSocialiteController.php new file mode 100644 index 00000000..d5fc3871 --- /dev/null +++ b/app/Http/Controllers/Auth/GitLabSocialiteController.php @@ -0,0 +1,96 @@ +with('loginError', 'GitLab login is not enabled.'); + } + + return Socialite::driver('gitlab')->redirect(); + } + + /** + * Handle the callback from GitLab after authentication. + */ + public function handleProviderCallback(): RedirectResponse + { + try { + $gitlabUser = Socialite::driver('gitlab')->user(); + $localUser = $this->findUserByEmail($gitlabUser->getEmail()); + + if (! $localUser instanceof User) { + $localUser = $this->createUser($gitlabUser); + } + + Auth::login($localUser); + + Log::debug('Logging GitLab user in.', ['email' => $gitlabUser->getEmail()]); + + Toaster::success(__('Successfully logged in via GitLab!')); + + return Redirect::route('overview'); + } catch (Exception $exception) { + Log::error('GitLab OAuth login error: ' . $exception->getMessage() . ' from ' . $exception::class); + + return Redirect::route('login')->with('loginError', 'Authentication failed. There may be an error with GitLab. Please try again later.'); + } + } + + /** + * Find a user by email and return the user's object if any. + * + * @param null|string $email The email of the user + */ + private function findUserByEmail(?string $email): ?User + { + return User::where('email', $email)->first(); + } + + /** + * Create a new user with GitLab data and return the user's object. + */ + private function createUser(SocialiteUser $socialiteUser): User + { + $user = User::create([ + 'name' => $socialiteUser->getName(), + 'email' => $socialiteUser->getEmail(), + ]); + + Mail::to($user->email)->queue(new WelcomeMail($user)); + + Log::debug('Creating new user from GitLab data.', ['email' => $socialiteUser->getEmail()]); + + return $user; + } +} diff --git a/config/services.php b/config/services.php index 4485b2cc..b56b33f8 100644 --- a/config/services.php +++ b/config/services.php @@ -32,4 +32,10 @@ 'client_secret' => env('GITHUB_CLIENT_SECRET'), 'redirect' => config('app.url') . '/auth/github/callback', ], + + 'gitlab' => [ + 'client_id' => env('GITLAB_CLIENT_ID'), + 'client_secret' => env('GITLAB_CLIENT_SECRET'), + 'redirect' => config('app.url') . '/auth/gitlab/callback', + ], ]; diff --git a/resources/views/components/icons/gitlab.blade.php b/resources/views/components/icons/gitlab.blade.php new file mode 100644 index 00000000..b753656b --- /dev/null +++ b/resources/views/components/icons/gitlab.blade.php @@ -0,0 +1,56 @@ +merge() }} + version="1.1" + id="svg85"> + + + + + + +{{----}} diff --git a/resources/views/livewire/pages/auth/login.blade.php b/resources/views/livewire/pages/auth/login.blade.php index c48669eb..3c90511f 100644 --- a/resources/views/livewire/pages/auth/login.blade.php +++ b/resources/views/livewire/pages/auth/login.blade.php @@ -99,6 +99,23 @@ public function login(): void @endif + @if (config('services.gitlab.client_id') && config('services.gitlab.client_secret')) +
+
+
+
+
+
+ +
+ + + {{ __('Login with GitLab') }} + +
+
+ @endif +
{{ __('By creating an account, you agree to our Terms of Service and our Privacy Policy.') }} diff --git a/resources/views/livewire/pages/auth/register.blade.php b/resources/views/livewire/pages/auth/register.blade.php index 7b4cfc61..d3c87d1d 100644 --- a/resources/views/livewire/pages/auth/register.blade.php +++ b/resources/views/livewire/pages/auth/register.blade.php @@ -101,6 +101,23 @@ public function register(): void
@endif + @if (config('services.gitlab.client_id') && config('services.gitlab.client_secret')) +
+
+
+
+
+
+ + +
+ @endif +
{{ __('By creating an account, you agree to our Terms of Service and our Privacy Policy.') }} diff --git a/routes/auth.php b/routes/auth.php index dbfde7c2..7cfbce5b 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,6 +1,7 @@ name('github.redirect'); + Route::get('auth/gitlab', [GitLabSocialiteController::class, 'redirectToProvider']) + ->name('gitlab.redirect'); }); Route::get('auth/github/callback', [GitHubSocialiteController::class, 'handleProviderCallback']) ->name('github.callback'); +Route::get('auth/gitlab/callback', [GitLabSocialiteController::class, 'handleProviderCallback']) + ->name('gitlab.callback'); + Route::middleware('auth')->group(function () { Volt::route('verify-email', 'pages.auth.verify-email')->name('verification.notice'); diff --git a/tests/Feature/Auth/GitlabLoginTest.php b/tests/Feature/Auth/GitlabLoginTest.php new file mode 100644 index 00000000..9d42ad5c --- /dev/null +++ b/tests/Feature/Auth/GitlabLoginTest.php @@ -0,0 +1,87 @@ +set('services.gitlab.client_id', 'fake-client-id'); + config()->set('services.gitlab.client_secret', 'fake-client-secret'); + + $response = $this->get(route('gitlab.redirect')); + + $response->assertRedirect(); +}); + +it('redirects back to login with error if GitLab login is not enabled', function (): void { + config()->set('services.gitlab.client_id', null); + config()->set('services.gitlab.client_secret', null); + + $response = $this->get(route('gitlab.redirect')); + + $response->assertRedirect(route('login')); + $response->assertSessionHas('loginError', 'GitLab login is not enabled.'); +}); + +it('logs in existing user', function (): void { + Toaster::fake(); + $user = User::factory()->create(); + + /** + * @var \Laravel\Socialite\Contracts\User|MockInterface + */ + $mockGitlabUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); + $mockGitlabUser->shouldReceive('getEmail')->andReturn($user->email); + + Socialite::shouldReceive('driver->user')->andReturn($mockGitlabUser); + $this->get(route('gitlab.callback')) + ->assertRedirect(route('overview')); + + Toaster::assertDispatched(__('Successfully logged in via GitLab!')); + + $this->assertAuthenticatedAs($user); +}); + +it('creates a new user if none exists', function (): void { + Toaster::fake(); + Mail::fake(); + $this->assertDatabaseMissing('users', [ + 'name' => 'New User', + 'email' => 'newuser@example.com', + ]); + /** + * @var \Laravel\Socialite\Contracts\User|MockInterface + */ + $mockGitlabUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); + $mockGitlabUser->shouldReceive('getEmail')->andReturn('newuser@example.com'); + $mockGitlabUser->shouldReceive('getName')->andReturn('New User'); + + Socialite::shouldReceive('driver->user')->andReturn($mockGitlabUser); + + $this->get(route('gitlab.callback')) + ->assertRedirect(route('overview')); + + Toaster::assertDispatched(__('Successfully logged in via GitLab!')); + + $user = User::where('email', 'newuser@example.com')->first(); + $this->assertAuthenticatedAs($user); + Mail::assertQueued(WelcomeMail::class); + $this->assertDatabaseHas('users', [ + 'name' => 'New User', + 'email' => 'newuser@example.com', + ]); +}); + +it('redirects back to login with error if an exception is thrown', function (): void { + Socialite::shouldReceive('driver->user')->andThrow(new InvalidStateException('Invalid State')); + + $response = $this->get(route('gitlab.callback')) + ->assertRedirect(route('login')); + $response->assertSessionHas('loginError', 'Authentication failed. There may be an error with GitLab. Please try again later.'); +});