Skip to content

Commit

Permalink
Oauth is now available (#2190)
Browse files Browse the repository at this point in the history
  • Loading branch information
ildyria authored Jan 17, 2024
1 parent 4cf52b7 commit c0c2e69
Show file tree
Hide file tree
Showing 44 changed files with 1,806 additions and 14 deletions.
44 changes: 43 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,46 @@ TRUSTED_PROXIES=null
#SKIP_DIAGNOSTICS_CHECKS=

VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

# Oauth token data
# XXX_REDIRECT_URI should be left as default unless you know exactly what you do.

# AMAZON_SIGNIN_CLIENT_ID=
# AMAZON_SIGNIN_SECRET=
# AMAZON_SIGNIN_REDIRECT_URI=/auth/amazon/redirect

# https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple
# Note: the client secret used for "Sign In with Apple" is a JWT token that can have a maximum lifetime of 6 months.
# The article above explains how to generate the client secret on demand and you'll need to update this every 6 months.
# To generate the client secret for each request, see Generating A Client Secret For Sign In With Apple On Each Request.
# https://bannister.me/blog/generating-a-client-secret-for-sign-in-with-apple-on-each-request
# APPLE_CLIENT_ID=
# APPLE_CLIENT_SECRET=
# APPLE_REDIRECT_URI=/auth/apple/redirect

# FACEBOOK_CLIENT_ID=
# FACEBOOK_CLIENT_SECRET=
# FACEBOOK_REDIRECT_URI=/auth/facebook/redirect

# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# GITHUB_REDIRECT_URI=/auth/github/redirect

# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GOOGLE_REDIRECT_URI=/auth/google/redirect

# MASTODON_DOMAIN=https://mastodon.social
# MASTODON_ID=
# MASTODON_SECRET=
# MASTODON_REDIRECT_URI=/auth/mastodon/redirect

# MICROSOFT_CLIENT_ID=
# MICROSOFT_CLIENT_SECRET=
# MICROSOFT_REDIRECT_URI=/auth/microsoft/redirect

# NEXTCLOUD_CLIENT_ID=
# NEXTCLOUD_CLIENT_SECRET=
# NEXTCLOUD_REDIRECT_URI=/auth/nextcloud/redirect
# NEXTCLOUD_BASE_URI=
24 changes: 24 additions & 0 deletions app/Enum/OauthProvidersType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace App\Enum;

use App\Enum\Traits\DecorateBackedEnum;

/**
* Enum OauthProvidersType.
*
* Available providers
*/
enum OauthProvidersType: string
{
use DecorateBackedEnum;

case AMAZON = 'amazon';
case APPLE = 'apple';
case FACEBOOK = 'facebook';
case GITHUB = 'github';
case GOOGLE = 'google';
case MASTODON = 'mastodon';
case MICROSOFT = 'microsoft';
case NEXTCLOUD = 'nextcloud';
}
164 changes: 164 additions & 0 deletions app/Http/Controllers/Oauth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<?php

namespace App\Http\Controllers;

use App\Enum\OauthProvidersType;
use App\Exceptions\Internal\LycheeInvalidArgumentException;
use App\Exceptions\Internal\LycheeLogicException;
use App\Exceptions\UnauthenticatedException;
use App\Exceptions\UnauthorizedException;
use App\Models\OauthCredential;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Session;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse as HttpFoundationRedirectResponse;

class Oauth extends Controller
{
public const OAUTH_REGISTER = 'register';

/**
* Provide a valid provider Enum from string.
*
* @param string $provider
*
* @return OauthProvidersType
*
* @throws LycheeInvalidArgumentException
*/
private function validateProviderOrDie(string $provider): OauthProvidersType
{
$providerEnum = OauthProvidersType::tryFrom($provider);
if ($providerEnum === null) {
throw new LycheeInvalidArgumentException('unkown Oauth provider type');
}

return $providerEnum;
}

/**
* Function callback from the Oauth server.
*
* @param string $provider
*
* @return Redirector|RedirectResponse
*/
public function redirected(string $provider)
{
$providerEnum = $this->validateProviderOrDie($provider);

// We are already logged in: Registration operation
if (Auth::check()) {
return $this->registerOrDie($providerEnum);
}

// Authentication operation
return $this->authenticateOrDie($providerEnum);
}

/**
* Function called to authenticate a user to an Oauth server.
*
* @param string $provider
*
* @return HttpFoundationRedirectResponse
*/
public function authenticate(string $provider)
{
if (Auth::check()) {
throw new UnauthorizedException('User already authenticated.');
}

$providerEnum = $this->validateProviderOrDie($provider);

return Socialite::driver($providerEnum->value)->redirect();
}

/**
* Add some security on registration.
*
* @param string $provider
*
* @return HttpFoundationRedirectResponse
*/
public function register(string $provider)
{
$providerEnum = $this->validateProviderOrDie($provider);

Auth::user() ?? throw new UnauthenticatedException();
if (!Request::hasValidSignature(false)) {
throw new UnauthorizedException('Registration attempted but not initialized.');
}

Session::put($providerEnum->value, self::OAUTH_REGISTER);

return Socialite::driver($providerEnum->value)->redirect();
}

/**
* Authenticate and redirect.
*
* @param OauthProvidersType $provider
*
* @return RedirectResponse
*/
private function authenticateOrDie(OauthProvidersType $provider)
{
$user = Socialite::driver($provider->value)->user();

$credential = OauthCredential::query()
->with(['user'])
->where('token_id', '=', $user->getId())
->where('provider', '=', $provider)
->first();

if ($credential === null) {
throw new UnauthorizedException('User not found!');
}

Auth::login($credential->user);

return redirect(route('livewire-gallery'));
}

/**
* Authenticate and redirect.
*
* @param OauthProvidersType $provider
*
* @return RedirectResponse
*/
private function registerOrDie(OauthProvidersType $provider)
{
if (Session::get($provider->value) !== self::OAUTH_REGISTER) {
throw new UnauthorizedException('Registration attempted but not authorized.');
}

$user = Socialite::driver($provider->value)->user();

/** @var User $authedUser */
$authedUser = Auth::user();

$count_existing = OauthCredential::query()
->where('provider', '=', $provider)
->where('user_id', '=', $authedUser->id)
->count();
if ($count_existing > 0) {
throw new LycheeLogicException('Oauth credential for that provider already exists.');
}

$credential = OauthCredential::create([
'provider' => $provider,
'user_id' => $authedUser->id,
'token_id' => $user->getId(),
]);
$credential->save();

return redirect(route('profile'));
}
}
80 changes: 80 additions & 0 deletions app/Livewire/Components/Forms/Profile/Oauth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace App\Livewire\Components\Forms\Profile;

use App\Enum\OauthProvidersType;
use App\Exceptions\UnauthenticatedException;
use App\Livewire\DTO\OauthData;
use App\Models\OauthCredential;
use App\Models\User;
use App\Policies\UserPolicy;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\URL;
use Livewire\Component;

/**
* Retrieve the API token for the current user.
* This is the Modal integration.
*/
class Oauth extends Component
{
/**
* Renders the modal content.
*
* @return View
*/
public function render(): View
{
$this->authorize(UserPolicy::CAN_EDIT, [User::class]);

return view('livewire.forms.profile.oauth');
}

public function clear(string $provider): void
{
$this->authorize(UserPolicy::CAN_EDIT, [User::class]);
$providerEnum = OauthProvidersType::from($provider);

/** @var User $user */
$user = Auth::user() ?? throw new UnauthenticatedException();
$user->oauthCredentials()->where('provider', '=', $providerEnum)->delete();
}

/**
* Return computed property for OauthData.
*
* @return array<string,OauthData>
*/
public function getOauthDataProperty(): array
{
$oauthData = [];

/** @var User $user */
$user = Auth::user() ?? throw new UnauthenticatedException();

$credentials = $user->oauthCredentials()->get();

foreach (OauthProvidersType::cases() as $provider) {
$client_id = config('services.' . $provider->value . '.client_id');
if ($client_id === null || $client_id === '') {
continue;
}

// We create a signed route for 5 minutes
$route = URL::signedRoute(
name: 'oauth-register',
parameters: ['provider' => $provider->value],
expiration: now()->addMinutes(5),
absolute: false);

$oauthData[$provider->value] = new OauthData(
providerType: $provider->value,
isEnabled: $credentials->search(fn (OauthCredential $c) => $c->provider === $provider) !== false,
registrationRoute: $route,
);
}

return $oauthData;
}
}
36 changes: 33 additions & 3 deletions app/Livewire/Components/Modals/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

namespace App\Livewire\Components\Modals;

use App\Enum\OauthProvidersType;
use App\Exceptions\Internal\QueryBuilderException;
use App\Http\RuleSets\LoginRuleSet;
use App\Metadata\Versions\FileVersion;
use App\Metadata\Versions\GitHubVersion;
use App\Metadata\Versions\InstalledVersion;
use App\Models\Configs;
use App\Models\User;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
Expand All @@ -22,7 +24,6 @@
*/
class Login extends Component
{
#[Locked] public bool $can_use_2fa = false;
#[Locked] public bool $is_new_release_available = false;
#[Locked] public bool $is_git_update_available = false;
#[Locked] public ?string $version = null;
Expand All @@ -46,8 +47,6 @@ public function render(): View
*/
public function mount(): void
{
$this->can_use_2fa = WebAuthnCredential::query()->whereNull('disabled_at')->count() > 0;

if (!Configs::getValueAsBool('hide_version_number')) {
$this->version = resolve(InstalledVersion::class)->getVersion()->toString();
}
Expand Down Expand Up @@ -96,4 +95,35 @@ public function submit(): void
$this->addError('wrongLogin', 'Wrong login or password.');
Log::error(__METHOD__ . ':' . __LINE__ . ' User (' . $data['username'] . ') has tried to log in from ' . request()->ip());
}

/**
* Check whether any user has a 2FA credential set.
*
* @return bool
*/
public function getCanUse2faProperty(): bool
{
return WebAuthnCredential::query()->whereNull('disabled_at')->count() > 0;
}

/**
* List the Oauth providers which are enabled.
*
* @return array
*/
public function getAvailableOauthProperty(): array
{
$oauthAvailable = [];

foreach (OauthProvidersType::cases() as $oauthProvider) {
$client_id = config('services.' . $oauthProvider->value . '.client_id');
if ($client_id === null || $client_id === '') {
continue;
}

$oauthAvailable[] = $oauthProvider->value;
}

return $oauthAvailable;
}
}
Loading

0 comments on commit c0c2e69

Please sign in to comment.