Add Oauth frontend and backend improvements (#718)

* better oauth provider loading

* add auth frontend

* add configs for all default providers

* add more default providers

* add env variables to enable oauth providers

* small refactor to link/ unlink routes

* add oauth tab to (admin) profile

* use redirects instead of exceptions

* add notification if no oauth user is found

* use import in config

* remove whmcs provider

* replace hardcoded links with `route`

* redirect to account page on unlink

* remove unnecessary controller and handle linking/ unlinking in action

* only show oauth tab if at least one oauth provider is enabled
This commit is contained in:
Boy132 2024-11-30 17:38:38 +01:00 committed by GitHub
parent 951fc73363
commit b208835ed4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 322 additions and 63 deletions

View File

@ -3,9 +3,12 @@
namespace App\Filament\Pages\Auth;
use Coderflex\FilamentTurnstile\Forms\Components\Turnstile;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\TextInput;
use Filament\Pages\Auth\Login as BaseLogin;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class Login extends BaseLogin
@ -19,6 +22,7 @@ class Login extends BaseLogin
$this->getLoginFormComponent(),
$this->getPasswordFormComponent(),
$this->getRememberFormComponent(),
$this->getOAuthFormComponent(),
Turnstile::make('captcha')
->hidden(!config('turnstile.turnstile_enabled'))
->validationMessages([
@ -49,6 +53,25 @@ class Login extends BaseLogin
->extraInputAttributes(['tabindex' => 1]);
}
protected function getOAuthFormComponent(): Component
{
$actions = [];
foreach (config('auth.oauth') as $name => $data) {
if (!$data['enabled']) {
continue;
}
$actions[] = Action::make("oauth_$name")
->label(Str::title($name))
->icon($data['icon'])
->color($data['color'])
->url(route('auth.oauth.redirect', ['driver' => $name], false));
}
return Actions::make($actions);
}
protected function getCredentialsFromFormData(array $data): array
{
$loginType = filter_var($data['login'], FILTER_VALIDATE_EMAIL) ? 'email' : 'username';

View File

@ -9,6 +9,7 @@ use App\Models\ApiKey;
use App\Models\User;
use App\Services\Users\ToggleTwoFactorService;
use App\Services\Users\TwoFactorSetupService;
use App\Services\Users\UserUpdateService;
use chillerlan\QRCode\Common\EccLevel;
use chillerlan\QRCode\Common\Version;
use chillerlan\QRCode\QRCode;
@ -16,6 +17,7 @@ use chillerlan\QRCode\QROptions;
use Closure;
use DateTimeZone;
use Exception;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Placeholder;
@ -33,7 +35,9 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Laravel\Socialite\Facades\Socialite;
/**
* @method User getUser()
@ -113,6 +117,53 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->options(fn (User $user) => $user->getAvailableLanguages()),
]),
Tab::make('OAuth')
->icon('tabler-brand-oauth')
->visible(function () {
foreach (config('auth.oauth') as $name => $data) {
if ($data['enabled']) {
return true;
}
}
return false;
})
->schema(function () {
$providers = [];
foreach (config('auth.oauth') as $name => $data) {
if (!$data['enabled']) {
continue;
}
$unlink = array_key_exists($name, $this->getUser()->oauth);
$providers[] = Action::make("oauth_$name")
->label(($unlink ? 'Unlink ' : 'Link ') . Str::title($name))
->icon($unlink ? 'tabler-unlink' : 'tabler-link')
->color($data['color'])
->action(function (UserUpdateService $updateService) use ($name, $unlink) {
if ($unlink) {
$oauth = auth()->user()->oauth;
unset($oauth[$name]);
$updateService->handle(auth()->user(), ['oauth' => $oauth]);
$this->fillForm();
Notification::make()
->title("OAuth provider '$name' unlinked")
->success()
->send();
} elseif (config("auth.oauth.$name.enabled")) {
redirect(Socialite::with($name)->redirect()->getTargetUrl());
}
});
}
return [Actions::make($providers)];
}),
Tab::make('2FA')
->icon('tabler-shield-lock')
->schema(function (TwoFactorSetupService $setupService) {

View File

@ -2,6 +2,8 @@
namespace App\Http\Controllers\Auth;
use App\Filament\Resources\UserResource\Pages\EditProfile;
use Filament\Notifications\Notification;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\RedirectResponse;
use Laravel\Socialite\Facades\Socialite;
@ -26,6 +28,11 @@ class OAuthController extends Controller
*/
protected function redirect(string $driver): RedirectResponse
{
// Driver is disabled - redirect to normal login
if (!config("auth.oauth.$driver.enabled")) {
return redirect()->route('auth.login');
}
return Socialite::with($driver)->redirect();
}
@ -34,6 +41,11 @@ class OAuthController extends Controller
*/
protected function callback(Request $request, string $driver): RedirectResponse
{
// Driver is disabled - redirect to normal login
if (!config("auth.oauth.$driver.enabled")) {
return redirect()->route('auth.login');
}
$oauthUser = Socialite::driver($driver)->user();
// User is already logged in and wants to link a new OAuth Provider
@ -43,15 +55,21 @@ class OAuthController extends Controller
$this->updateService->handle($request->user(), ['oauth' => $oauth]);
return redirect()->route('account');
return redirect(EditProfile::getUrl(['tab' => '-oauth-tab']));
}
try {
$user = User::query()->whereJsonContains('oauth->'. $driver, $oauthUser->getId())->firstOrFail();
$this->auth->guard()->login($user, true);
} catch (Exception $e) {
} catch (Exception) {
// No user found - redirect to normal login
Notification::make()
->title('No linked User found')
->danger()
->persistent()
->send();
return redirect()->route('auth.login');
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Http\Controllers\Base;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Laravel\Socialite\Facades\Socialite;
use App\Http\Controllers\Controller;
use App\Services\Users\UserUpdateService;
use Illuminate\Http\Response;
class OAuthController extends Controller
{
/**
* OAuthController constructor.
*/
public function __construct(
private UserUpdateService $updateService
) {}
/**
* Link a new OAuth
*/
protected function link(Request $request): RedirectResponse
{
$driver = $request->get('driver');
return Socialite::with($driver)->redirect();
}
/**
* Remove a OAuth link
*/
protected function unlink(Request $request): Response
{
$oauth = $request->user()->oauth;
unset($oauth[$request->get('driver')]);
$this->updateService->handle($request->user(), ['oauth' => $oauth]);
return new Response('', Response::HTTP_NO_CONTENT);
}
}

View File

@ -20,7 +20,6 @@ use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use SocialiteProviders\Discord\Provider;
use SocialiteProviders\Manager\SocialiteWasCalled;
class AppServiceProvider extends ServiceProvider
@ -69,8 +68,19 @@ class AppServiceProvider extends ServiceProvider
Scramble::registerApi('client', ['api_path' => 'api/client', 'info' => ['version' => '1.0']])->afterOpenApiGenerated($bearerTokens);
Scramble::registerApi('remote', ['api_path' => 'api/remote', 'info' => ['version' => '1.0']])->afterOpenApiGenerated($bearerTokens);
Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite('discord', Provider::class);
$oauthProviders = [];
foreach (config('auth.oauth') as $name => $data) {
config()->set("services.$name", array_merge($data['service'], ['redirect' => "/auth/oauth/callback/$name"]));
if (isset($data['provider'])) {
$oauthProviders[$name] = $data['provider'];
}
}
Event::listen(function (SocialiteWasCalled $event) use ($oauthProviders) {
foreach ($oauthProviders as $name => $provider) {
$event->extendSocialite($name, $provider);
}
});
FilamentColor::register([

View File

@ -30,7 +30,9 @@
"predis/predis": "~2.1.1",
"ryangjchandler/blade-tabler-icons": "^2.3",
"s1lentium/iptools": "~1.2.0",
"socialiteproviders/authentik": "^5.2",
"socialiteproviders/discord": "^4.2",
"socialiteproviders/steam": "^4.2",
"spatie/laravel-fractal": "^6.2",
"spatie/laravel-permission": "^6.9",
"spatie/laravel-query-builder": "^5.8.1",

100
composer.lock generated
View File

@ -6825,6 +6825,56 @@
},
"time": "2022-08-17T14:28:59+00:00"
},
{
"name": "socialiteproviders/authentik",
"version": "5.2.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Authentik.git",
"reference": "4cf129cf04728a38e0531c54454464b162f0fa66"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Authentik/zipball/4cf129cf04728a38e0531c54454464b162f0fa66",
"reference": "4cf129cf04728a38e0531c54454464b162f0fa66",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0",
"socialiteproviders/manager": "^4.4"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Authentik\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "rf152",
"email": "git@rf152.co.uk"
}
],
"description": "Authentik OAuth2 Provider for Laravel Socialite",
"keywords": [
"authentik",
"laravel",
"oauth",
"provider",
"socialite"
],
"support": {
"docs": "https://socialiteproviders.com/authentik",
"issues": "https://github.com/socialiteproviders/providers/issues",
"source": "https://github.com/socialiteproviders/providers"
},
"time": "2023-11-07T22:21:16+00:00"
},
{
"name": "socialiteproviders/discord",
"version": "4.2.0",
@ -6949,6 +6999,56 @@
},
"time": "2024-11-10T01:56:18+00:00"
},
{
"name": "socialiteproviders/steam",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Steam.git",
"reference": "922f82a26fb7243d7e7ff2ec8ba7e957e7b9eeb7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Steam/zipball/922f82a26fb7243d7e7ff2ec8ba7e957e7b9eeb7",
"reference": "922f82a26fb7243d7e7ff2ec8ba7e957e7b9eeb7",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.2 || ^8.0",
"socialiteproviders/manager": "~4.0"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Steam\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christopher Eklund",
"email": "eklundchristopher@gmail.com"
}
],
"description": "Steam OpenID Provider for Laravel Socialite",
"keywords": [
"OpenId",
"laravel",
"provider",
"socialite",
"steam"
],
"support": {
"docs": "https://socialiteproviders.com/steam",
"issues": "https://github.com/socialiteproviders/providers/issues",
"source": "https://github.com/socialiteproviders/providers"
},
"time": "2022-03-28T22:38:40+00:00"
},
{
"name": "spatie/color",
"version": "1.6.0",

View File

@ -1,5 +1,7 @@
<?php
use Filament\Support\Colors\Color;
return [
'lockout' => [
@ -23,4 +25,115 @@ return [
],
],
'oauth' => [
// Default providers
'facebook' => [
'enabled' => env('OAUTH_FACEBOOK_ENABLED'),
'icon' => 'tabler-brand-facebook',
'color' => Color::hex('#1877f2'),
'service' => [
'client_id' => env('OAUTH_FACEBOOK_CLIENT_ID'),
'client_secret' => env('OAUTH_FACEBOOK_CLIENT_SECRET'),
],
],
'x' => [
'enabled' => env('OAUTH_X_ENABLED'),
'icon' => 'tabler-brand-x',
'color' => Color::hex('#1da1f2'),
'service' => [
'client_id' => env('OAUTH_X_CLIENT_ID'),
'client_secret' => env('OAUTH_X_CLIENT_SECRET'),
],
],
'linkedin' => [
'enabled' => env('OAUTH_LINKEDIN_ENABLED'),
'icon' => 'tabler-brand-linkedin',
'color' => Color::hex('#0a66c2'),
'service' => [
'client_id' => env('OAUTH_LINKEDIN_CLIENT_ID'),
'client_secret' => env('OAUTH_LINKEDIN_CLIENT_SECRET'),
],
],
'google' => [
'enabled' => env('OAUTH_GOOGLE_ENABLED'),
'icon' => 'tabler-brand-google',
'color' => Color::hex('#4285f4'),
'service' => [
'client_id' => env('OAUTH_GOOGLE_CLIENT_ID'),
'client_secret' => env('OAUTH_GOOGLE_CLIENT_SECRET'),
],
],
'github' => [
'enabled' => env('OAUTH_GITHUB_ENABLED'),
'icon' => 'tabler-brand-github',
'color' => Color::hex('#4078c0'),
'service' => [
'client_id' => env('OAUTH_GITHUB_CLIENT_ID'),
'client_secret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
],
],
'gitlab' => [
'enabled' => env('OAUTH_GITLAB_ENABLED'),
'icon' => 'tabler-brand-gitlab',
'color' => Color::hex('#fca326'),
'service' => [
'client_id' => env('OAUTH_GITLAB_CLIENT_ID'),
'client_secret' => env('OAUTH_GITLAB_CLIENT_SECRET'),
],
],
'bitbucket' => [
'enabled' => env('OAUTH_BITBUCKET_ENABLED'),
'icon' => 'tabler-brand-bitbucket',
'color' => Color::hex('#205081'),
'service' => [
'client_id' => env('OAUTH_BITBUCKET_CLIENT_ID'),
'client_secret' => env('OAUTH_BITBUCKET_CLIENT_SECRET'),
],
],
'slack' => [
'enabled' => env('OAUTH_SLACK_ENABLED'),
'icon' => 'tabler-brand-slack',
'color' => Color::hex('#6ecadc'),
'service' => [
'client_id' => env('OAUTH_SLACK_CLIENT_ID'),
'client_secret' => env('OAUTH_SLACK_CLIENT_SECRET'),
],
],
// Additional providers from socialiteproviders.com
'authentik' => [
'enabled' => env('OAUTH_AUTHENTIK_ENABLED'),
'icon' => null,
'color' => Color::hex('#fd4b2d'),
'service' => [
'base_url' => env('OAUTH_AUTHENTIK_BASE_URL'),
'client_id' => env('OAUTH_AUTHENTIK_CLIENT_ID'),
'client_secret' => env('OAUTH_AUTHENTIK_CLIENT_SECRET'),
],
'provider' => \SocialiteProviders\Authentik\Provider::class,
],
'discord' => [
'enabled' => env('OAUTH_DISCORD_ENABLED'),
'icon' => 'tabler-brand-discord',
'color' => Color::hex('#5865F2'),
'service' => [
'client_id' => env('OAUTH_DISCORD_CLIENT_ID'),
'client_secret' => env('OAUTH_DISCORD_CLIENT_SECRET'),
],
'provider' => \SocialiteProviders\Discord\Provider::class,
],
'steam' => [
'enabled' => env('OAUTH_STEAM_ENABLED'),
'icon' => 'tabler-brand-steam',
'color' => Color::hex('#00adee'),
'service' => [
'client_secret' => env('OAUTH_STEAM_CLIENT_SECRET'),
'allowed_hosts' => [
env('APP_URL'),
],
],
'provider' => \SocialiteProviders\Steam\Provider::class,
],
],
];

View File

@ -9,16 +9,4 @@ return [
'scheme' => 'https',
],
'github' => [
'client_id' => env('OAUTH_GITHUB_CLIENT_ID'),
'client_secret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
'redirect' => '/auth/oauth/callback/github',
],
'discord' => [
'client_id' => env('OAUTH_DISCORD_CLIENT_ID'),
'client_secret' => env('OAUTH_DISCORD_CLIENT_SECRET'),
'redirect' => '/auth/oauth/callback/discord',
],
];

View File

@ -10,9 +10,6 @@ Route::get('/account', [Base\IndexController::class, 'index'])
->withoutMiddleware(RequireTwoFactorAuthentication::class)
->name('account');
Route::get('/account/oauth/link', [Base\OAuthController::class, 'link'])->name('account.oauth.link');
Route::get('/account/oauth/unlink', [Base\OAuthController::class, 'unlink'])->name('account.oauth.unlink');
Route::get('/locales/locale.json', Base\LocaleController::class)
->withoutMiddleware(['auth', RequireTwoFactorAuthentication::class])
->where('namespace', '.*');