diff --git a/src/app/Http/AuthSession.php b/src/app/Http/AuthSession.php new file mode 100644 --- /dev/null +++ b/src/app/Http/AuthSession.php @@ -0,0 +1,326 @@ +prefix = $prefix; + $this->ttl = $ttl; + } + + /** + * Get the name of the session. + * + * @return string + */ + public function getName() + { + throw new \Exception("Not implemented"); + } + + /** + * Set the name of the session. + * + * @param string $name + * @return void + */ + public function setName($name) + { + throw new \Exception("Not implemented"); + } + + /** + * Get the current session ID. + * + * @return string + */ + public function getId() + { + throw new \Exception("Not implemented"); + } + + /** + * Set the session ID. + * + * @param string $id + * @return void + */ + public function setId($id) + { + throw new \Exception("Not implemented"); + } + + /** + * Start the session, reading the data from a handler. + * + * @return bool + */ + public function start() + { + throw new \Exception("Not implemented"); + } + + /** + * Save the session data to storage. + * + * @return void + */ + public function save($prefix = null) + { + foreach ($this->data as $key => $value) { + Cache::put($prefix . ':' . $key, $value, $this->ttl); + } + } + + /** + * Get all of the session data. + * + * @return array + */ + public function all() + { + throw new \Exception("Not implemented"); + } + + /** + * Checks if a key exists. + * + * @param string|array $key + * @return bool + */ + public function exists($key) + { + throw new \Exception("Not implemented"); + } + + /** + * Checks if a key is present and not null. + * + * @param string|array $key + * @return bool + */ + public function has($key) + { + throw new \Exception("Not implemented"); + } + + /** + * Get an item from the session. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function get($key, $default = null) + { + if (array_key_exists($key, $this->data)) { + return $this->data[$key]; + } + + if (strlen($this->prefix)) { + return Cache::get($this->prefix . ':' . $key) ?? $default; + } + + return $default; + } + + /** + * Get the value of a given key and then forget it. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function pull($key, $default = null) + { + if (array_key_exists($key, $this->data)) { + $value = $this->data[$key]; + unset($this->data[$key]); + return $value; + } + + if (strlen($this->prefix)) { + return Cache::pull($this->prefix . ':' . $key) ?? $default; + } + + return $default; + } + + /** + * Put a key / value pair or array of key / value pairs in the session. + * + * @param string|array $key + * @param mixed $value + * @return void + */ + public function put($key, $value = null) + { + $this->data[$key] = $value; + } + + /** + * Get the CSRF token value. + * + * @return string + */ + public function token() + { + throw new \Exception("Not implemented"); + } + + /** + * Regenerate the CSRF token value. + * + * @return void + */ + public function regenerateToken() + { + throw new \Exception("Not implemented"); + } + + /** + * Remove an item from the session, returning its value. + * + * @param string $key + * @return mixed + */ + public function remove($key) + { + throw new \Exception("Not implemented"); + } + + /** + * Remove one or many items from the session. + * + * @param string|array $keys + * @return void + */ + public function forget($keys) + { + throw new \Exception("Not implemented"); + } + + /** + * Remove all of the items from the session. + * + * @return void + */ + public function flush() + { + throw new \Exception("Not implemented"); + } + + /** + * Flush the session data and regenerate the ID. + * + * @return bool + */ + public function invalidate() + { + throw new \Exception("Not implemented"); + } + + /** + * Generate a new session identifier. + * + * @param bool $destroy + * @return bool + */ + public function regenerate($destroy = false) + { + throw new \Exception("Not implemented"); + } + + /** + * Generate a new session ID for the session. + * + * @param bool $destroy + * @return bool + */ + public function migrate($destroy = false) + { + throw new \Exception("Not implemented"); + } + + /** + * Determine if the session has been started. + * + * @return bool + */ + public function isStarted() + { + throw new \Exception("Not implemented"); + } + + /** + * Get the previous URL from the session. + * + * @return string|null + */ + public function previousUrl() + { + throw new \Exception("Not implemented"); + } + + /** + * Set the "previous" URL in the session. + * + * @param string $url + * @return void + */ + public function setPreviousUrl($url) + { + throw new \Exception("Not implemented"); + } + + /** + * Get the session handler instance. + * + * @return \SessionHandlerInterface + */ + public function getHandler() + { + throw new \Exception("Not implemented"); + } + + /** + * Determine if the session handler needs a request. + * + * @return bool + */ + public function handlerNeedsRequest() + { + throw new \Exception("Not implemented"); + } + + /** + * Set the request on the handler instance. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + public function setRequestOnHandler($request) + { + throw new \Exception("Not implemented"); + } +} diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\API; +use App\Http\AuthSession; use App\Http\Controllers\Controller; use App\User; use Illuminate\Http\Request; @@ -9,6 +10,7 @@ use Illuminate\Support\Facades\Validator; use Laravel\Passport\TokenRepository; use Laravel\Passport\RefreshTokenRepository; +use Laravel\Socialite\Facades\Socialite; class AuthController extends Controller { @@ -127,6 +129,165 @@ ]); } + /** + * OAuth callback. Handles return from the provider's site. + * + * @param string $provider OAuth provider + * + * @return \Illuminate\View\View + */ + public function oAuthCallback(string $provider) + { + $env = \App\Utils::uiEnv(); + $request = request(); + + if ($error = $request->input('error')) { + \Log::warning(ucfirst($provider) . " OAuth error ({$error}): " . $request->input('error_description')); + // TODO: Display a localized error instead the OAuth error code from the provider + $env['userError'] = $error; + return view($env['view'])->with('env', $env); + } + + // Use our own session handler. Normally we don't use sessions, but we need + // it here, as not all OAuth drivers support stateless(), e.g. Twitter v2 + $request->setLaravelSession($session = new AuthSession($request->input('state'))); + + try { + \config(["services.{$provider}.redirect" => \App\Utils::serviceUrl("oauth/callback/{$provider}")]); + + $oauth_user = Socialite::driver($provider)->user(); + } catch (\Throwable $error) { + \Log::error($error); // FIXME: warning? + // TODO: A better (localized) error message + $env['userError'] = 'External authentication failed!'; + return view($env['view'])->with('env', $env); + } + + // \Log::info(print_r($oauth_user, true)); + + if ($login_property = \config("services.{$provider}.login_property")) { + $email = strtolower((string) $oauth_user->{$login_property}); + if (!strpos($email, '@')) { + // FIXME: Note that an email address built this way is not a valid address + $email .= '@' . $provider; + } + } else { + $email = $oauth_user->getEmail(); + if (!$email) { + // TODO: localized error + $env['userError'] = 'External authentication failed!'; + return view($env['view'])->with('env', $env); + } + } + + // TODO: Sanity check on $email: max length, chars, make sure it's a valid email? + + $user = User::where('email', $email)->first(); + + if (!$user) { + $user = new User(); + $user->email = $email; + $user->role = User::ROLE_GUEST; + } + + if ($user->role !== User::ROLE_GUEST) { + \Log::warning(ucfirst($provider) . " OAuth error: Local user"); + // TODO: A better (localized) error message + $env['userError'] = 'External authentication is not for local users!'; + return view($env['view'])->with('env', $env); + } + + // Set fake password so we can use logonResponse() below + // TODO: Find a way to create tokens without this hassle with a password + $user->password = $password = bin2hex(random_bytes(16)); + $user->save(); + + $settings = [ + 'oauth_provider' => $provider, + 'oauth_name' => $oauth_user->getName(), + 'oauth_id' => $oauth_user->getId(), + // 'oauth_token' => $oauth_user->token, + // 'oauth_refresh_token' => $oauth_user->refreshToken, + // 'oauth_expires_in' => $oauth_user->expiresIn, + ]; + + // $user->setSettings($settings); + + // Log-in the user, get tokens + $response = self::logonResponse($user, $password); + $response = $response->getData(true); + + // TODO: Direct the user to the page that was requested initially + $response['redirect'] = 'dashboard'; + + $env['userResponse'] = $response; + + // The logonResponse() call above will reset the current request + // reference making an issue with assets, etc. + // We have to reset it to the original request we're handling here. + \app('url')->setRequest($request); + + return view($env['view'])->with('env', $env); + } + + /** + * OAuth configuration for the client-side. + * + * @return array List of available providers + */ + public static function oAuthConfig(): array + { + $providers = [ + 'facebook' => !empty(\config('services.facebook.client_id')), + 'github' => !empty(\config('services.github.client_id')), + 'google' => !empty(\config('services.google.client_id')), + 'twitter' => !empty(\config('services.twitter.client_id')), + // TODO: For providers like keycloak we'd need another approach. Consider: + // 1. What if you want to use more than one Keycloak-based service? + // 2. The button label should probably not be "Keycloak" in any case. + // 3. The button icon should be configurable + 'keycloak' => !empty(\config('services.keycloak.client_id')), + ]; + + return array_keys(array_filter($providers)); + } + + /** + * Creates an redirect URL to the OAuth provider's site. + * + * @param string $provider OAuth provider + * + * @return \Illuminate\Http\JsonResponse + */ + public function oAuthRedirect(string $provider) + { + // Use our own session handler. Normally we don't use sessions, but we need + // it here, as not all OAuth drivers support stateless(), e.g. Twitter v2 + request()->setLaravelSession($session = new AuthSession()); + + try { + \config(["services.{$provider}.redirect" => \App\Utils::serviceUrl("oauth/callback/{$provider}")]); + + $url = Socialite::driver($provider)->redirect()->getTargetUrl(); + + // Save the session data to the cache + if (preg_match('/[?&]state=([^&]+)/', $url, $matches)) { + $session->save($matches[1]); + } else { + // error + } + } catch (\Throwable $error) { + \Log::error($error); + // TODO: Better error (localized) message + return response()->json(['status' => 'error', 'message' => "Redirect to OAuth provider failed"], 500); + } + + return response()->json([ + 'status' => 'success', + 'redirect' => $url, + ]); + } + /** * Refresh a token. * diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -164,6 +164,10 @@ ] ); + if ($user->role === User::ROLE_GUEST) { + return []; + } + // Check if the user is a controller of his wallet $isController = $user->canDelete($user); @@ -351,17 +355,28 @@ { $response = array_merge($user->toArray(), self::objectState($user)); - $wallet = $user->wallet(); - - // IsLocked flag to lock the user to the Wallet page only - $response['isLocked'] = (!$user->isActive() && ($plan = $wallet->plan()) && $plan->mode == Plan::MODE_MANDATE); - // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } + if ($user->role === User::ROLE_GUEST) { + // TODO: For now we set some required properties to empty/dummy value + // This will very likely change in the future + $response['wallets'] = []; + $response['accounts'] = []; + $response['statusInfo'] = ['skus' => [], 'process' => [], 'isDone' => true]; + $response['isGuest'] = true; + + return $response; + } + + $wallet = $user->wallet(); + + // IsLocked flag to lock the user to the Wallet page only + $response['isLocked'] = (!$user->isActive() && ($plan = $wallet->plan()) && $plan->mode == Plan::MODE_MANDATE); + // Status info $response['statusInfo'] = self::statusInfo($user); diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -49,6 +49,10 @@ // 'throttle:api', \Illuminate\Routing\Middleware\SubstituteBindings::class, ], + + 'session' => [ + \Illuminate\Session\Middleware\StartSession::class, + ], ]; /** diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -61,6 +61,11 @@ // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); + // Don't need wallet, nor jobs for an external user + if ($user->role === User::ROLE_GUEST) { + return; + } + $user->wallets()->create(); // Create user record in the backend (LDAP and IMAP) diff --git a/src/app/Providers/EventServiceProvider.php b/src/app/Providers/EventServiceProvider.php --- a/src/app/Providers/EventServiceProvider.php +++ b/src/app/Providers/EventServiceProvider.php @@ -2,9 +2,6 @@ namespace App\Providers; -use Illuminate\Support\Facades\Event; -use Illuminate\Auth\Events\Registered; -use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider @@ -12,11 +9,12 @@ /** * The event listener mappings for the application. * - * @var array> + * @var array> */ protected $listen = [ - Registered::class => [ - SendEmailVerificationNotification::class, + \SocialiteProviders\Manager\SocialiteWasCalled::class => [ + // Extra providers + \SocialiteProviders\Keycloak\KeycloakExtendSocialite::class . '@handle', ], ]; diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -61,6 +61,10 @@ // a restricted user public const STATUS_RESTRICTED = 1 << 7; + public const ROLE_ADMIN = 'admin'; + public const ROLE_GUEST = 'guest'; + public const ROLE_RESELLER = 'reseller'; + /** @var int The allowed states for this object used in StatusPropertyTrait */ private int $allowed_states = self::STATUS_NEW | self::STATUS_ACTIVE | diff --git a/src/app/Utils.php b/src/app/Utils.php --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -486,6 +486,8 @@ $env['languages'] = \App\Http\Controllers\ContentController::locales(); $env['menu'] = \App\Http\Controllers\ContentController::menu(); + $env['oauthProviders'] = \App\Http\Controllers\API\AuthController::oAuthConfig(); + return $env; } diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -25,17 +25,19 @@ "laravel/horizon": "^5.9", "laravel/octane": "^2.0", "laravel/passport": "^11.3", + "laravel/socialite": "^5.8", "laravel/tinker": "^2.8", + "lcobucci/jwt": "^5.0", "league/flysystem-aws-s3-v3": "^3.0", "mlocati/spf-lib": "^3.1", "mollie/laravel-mollie": "^2.22", "pear/crypt_gpg": "^1.6.6", "predis/predis": "^2.0", "sabre/vobject": "^4.5", + "socialiteproviders/keycloak": "^5.3", "spatie/laravel-translatable": "^6.5", "spomky-labs/otphp": "~10.0.0", - "stripe/stripe-php": "^10.7", - "lcobucci/jwt": "^5.0" + "stripe/stripe-php": "^10.7" }, "require-dev": { "code-lts/doctum": "^5.5.1", diff --git a/src/config/services.php b/src/config/services.php --- a/src/config/services.php +++ b/src/config/services.php @@ -34,6 +34,17 @@ 'secret' => env('SPARKPOST_SECRET'), ], + 'openexchangerates' => [ + 'api_key' => env('OPENEXCHANGERATES_API_KEY', null), + ], + + + /* + |-------------------------------------------------------------------------- + | Payment Providers + |-------------------------------------------------------------------------- + */ + 'payment_provider' => env('PAYMENT_PROVIDER', 'mollie'), 'mollie' => [ @@ -52,9 +63,11 @@ 'api_verify_tls' => env('COINBASE_VERIFY_TLS', true), ], - 'openexchangerates' => [ - 'api_key' => env('OPENEXCHANGERATES_API_KEY', null), - ], + /* + |-------------------------------------------------------------------------- + | Kolab Services + |-------------------------------------------------------------------------- + */ 'dav' => [ 'uri' => env('DAV_URI', 'https://proxy/'), @@ -70,5 +83,43 @@ 'webmail' => [ 'uri' => env('WEBMAIL_URI', 'http://roundcube/roundcubemail/'), - ] + ], + + /* + |-------------------------------------------------------------------------- + | OAuth Providers + |-------------------------------------------------------------------------- + */ + + 'facebook' => [ + 'client_id' => env('FACEBOOK_CLIENT_ID'), + 'client_secret' => env('FACEBOOK_CLIENT_SECRET'), + 'login_property' => 'nickname', + ], + + 'github' => [ + 'client_id' => env('GITHUB_CLIENT_ID'), + 'client_secret' => env('GITHUB_CLIENT_SECRET'), + 'login_property' => 'nickname', + ], + + 'google' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET'), + ], + + 'keycloak' => [ + 'client_id' => env('KEYCLOAK_CLIENT_ID'), + 'client_secret' => env('KEYCLOAK_CLIENT_SECRET'), + 'base_url' => env('KEYCLOAK_BASE_URL'), // Specify your keycloak server URL here + 'realms' => env('KEYCLOAK_REALM') // Specify your keycloak realm + ], + + 'twitter' => [ + 'client_id' => env('TWITTER_CLIENT_ID'), + 'client_secret' => env('TWITTER_CLIENT_SECRET'), + 'oauth' => 2, // enable the twitter-oauth-2 driver + 'login_property' => 'nickname', + ], + ]; diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -3,7 +3,7 @@
- + {{ $t('dashboard.myaccount') }} @@ -37,7 +37,7 @@ {{ $t('dashboard.policies') }} - + {{ $t('dashboard.webmail') }} diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -40,11 +40,13 @@ {{ $t('login.forgot_password') }} {{ $t('login.webmail') }}
+ diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -22,6 +22,7 @@ ], function () { Route::post('login', [API\AuthController::class, 'login']); + Route::post('oauth/{provider}', API\AuthController::class . '@oAuthRedirect'); Route::group( ['middleware' => 'auth:api'], diff --git a/src/routes/web.php b/src/routes/web.php --- a/src/routes/web.php +++ b/src/routes/web.php @@ -54,5 +54,7 @@ 'as' => 'tokens.destroy', ]); }); + + Route::get('callback/{provider}', Controllers\API\AuthController::class . '@oAuthCallback'); } );