Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add GitLab authentication feature. #17

Merged
merged 1 commit into from
Aug 12, 2024
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
96 changes: 96 additions & 0 deletions app/Http/Controllers/Auth/GitLabSocialiteController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Mail\User\WelcomeMail;
use App\Models\User;
use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Redirect;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Laravel\Socialite\Facades\Socialite;
use Masmerise\Toaster\Toaster;
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;

/**
* Handles GitLab OAuth authentication for the application.
*
* This controller manages the GitLab authentication flow, including
* redirecting users to GitLab, handling callbacks, and user creation/login.
*/
class GitLabSocialiteController extends Controller
{
/**
* Redirect the user to the GitLab authentication page.
*/
public function redirectToProvider(): Redirect|SymfonyRedirectResponse|RedirectResponse
{
if (! config('services.gitlab.client_id') || ! config('services.gitlab.client_secret')) {
Log::debug('GitLab login is not enabled. Redirecting back to login.');

return Redirect::route('login')->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;
}
}
6 changes: 6 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
];
56 changes: 56 additions & 0 deletions resources/views/components/icons/gitlab.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="24"
height="24"
viewBox="0 0 1000 1000"
{{ $attributes->merge() }}
version="1.1"
id="svg85">
<sodipodi:namedview
id="namedview87"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="1"
inkscape:cx="991.5"
inkscape:cy="964.5"
inkscape:window-width="1126"
inkscape:window-height="895"
inkscape:window-x="774"
inkscape:window-y="12"
inkscape:window-maximized="0"
inkscape:current-layer="svg85" />
<defs
id="defs74">
<style
id="style72">.cls-1{fill:#e24329;}.cls-2{fill:#fc6d26;}.cls-3{fill:#fca326;}</style>
</defs>
<g
id="LOGO"
transform="matrix(5.2068817,0,0,5.2068817,-489.30756,-507.76085)">
<path
class="cls-1"
d="m 282.83,170.73 -0.27,-0.69 -26.14,-68.22 a 6.81,6.81 0 0 0 -2.69,-3.24 7,7 0 0 0 -8,0.43 7,7 0 0 0 -2.32,3.52 l -17.65,54 h -71.47 l -17.65,-54 a 6.86,6.86 0 0 0 -2.32,-3.53 7,7 0 0 0 -8,-0.43 6.87,6.87 0 0 0 -2.69,3.24 L 97.44,170 l -0.26,0.69 a 48.54,48.54 0 0 0 16.1,56.1 l 0.09,0.07 0.24,0.17 39.82,29.82 19.7,14.91 12,9.06 a 8.07,8.07 0 0 0 9.76,0 l 12,-9.06 19.7,-14.91 40.06,-30 0.1,-0.08 a 48.56,48.56 0 0 0 16.08,-56.04 z"
id="path76" />
<path
class="cls-2"
d="m 282.83,170.73 -0.27,-0.69 a 88.3,88.3 0 0 0 -35.15,15.8 L 190,229.25 c 19.55,14.79 36.57,27.64 36.57,27.64 l 40.06,-30 0.1,-0.08 a 48.56,48.56 0 0 0 16.1,-56.08 z"
id="path78" />
<path
class="cls-3"
d="m 153.43,256.89 19.7,14.91 12,9.06 a 8.07,8.07 0 0 0 9.76,0 l 12,-9.06 19.7,-14.91 c 0,0 -17.04,-12.89 -36.59,-27.64 -19.55,14.75 -36.57,27.64 -36.57,27.64 z"
id="path80" />
<path
class="cls-2"
d="M 132.58,185.84 A 88.19,88.19 0 0 0 97.44,170 l -0.26,0.69 a 48.54,48.54 0 0 0 16.1,56.1 l 0.09,0.07 0.24,0.17 39.82,29.82 c 0,0 17,-12.85 36.57,-27.64 z"
id="path82" />
</g>
</svg>
{{----}}
17 changes: 17 additions & 0 deletions resources/views/livewire/pages/auth/login.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ public function login(): void
</div>
@endif

@if (config('services.gitlab.client_id') && config('services.gitlab.client_secret'))
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-700"></div>
</div>
</div>

<div class="mt-6">
<a href="{{ route('gitlab.redirect') }}" class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700">
<x-icons.gitlab class="w-5 h-5 mr-3"/>
<span>{{ __('Login with GitLab') }}</span>
</a>
</div>
</div>
@endif

<div class="text-center mt-8">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
{{ __('By creating an account, you agree to our Terms of Service and our Privacy Policy.') }}
Expand Down
17 changes: 17 additions & 0 deletions resources/views/livewire/pages/auth/register.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ public function register(): void
</div>
@endif

@if (config('services.gitlab.client_id') && config('services.gitlab.client_secret'))
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-700"></div>
</div>
</div>

<div class="mt-6">
<a href="{{ route('gitlab.redirect') }}" class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-700">
<x-icons.gitlab class="w-5 h-5 mr-3"/>
<span>{{ __('Login with GitLab') }}</span>
</a>
</div>
</div>
@endif

<div class="text-center mt-8">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
{{ __('By creating an account, you agree to our Terms of Service and our Privacy Policy.') }}
Expand Down
6 changes: 6 additions & 0 deletions routes/auth.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use App\Http\Controllers\Auth\GitHubSocialiteController;
use App\Http\Controllers\Auth\GitLabSocialiteController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
use Livewire\Volt\Volt;
Expand All @@ -13,11 +14,16 @@

Route::get('auth/github', [GitHubSocialiteController::class, 'redirectToProvider'])
->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');

Expand Down
87 changes: 87 additions & 0 deletions tests/Feature/Auth/GitlabLoginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

use App\Mail\User\WelcomeMail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\InvalidStateException;
use Masmerise\Toaster\Toaster;
use Mockery\MockInterface;

it('redirects to GitLab when client ID and secret are set', function (): void {
config()->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' => '[email protected]',
]);
/**
* @var \Laravel\Socialite\Contracts\User|MockInterface
*/
$mockGitlabUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class);
$mockGitlabUser->shouldReceive('getEmail')->andReturn('[email protected]');
$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', '[email protected]')->first();
$this->assertAuthenticatedAs($user);
Mail::assertQueued(WelcomeMail::class);
$this->assertDatabaseHas('users', [
'name' => 'New User',
'email' => '[email protected]',
]);
});

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.');
});
Loading