Skip to content

Commit

Permalink
feat: support Google SSO
Browse files Browse the repository at this point in the history
  • Loading branch information
phanan committed Jul 6, 2024
1 parent 58f62c2 commit bd8ada1
Show file tree
Hide file tree
Showing 46 changed files with 1,097 additions and 86 deletions.
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ ALLOW_DOWNLOAD=true
# Whether to create a backup of a song when deleting it from the filesystem.
BACKUP_ON_DELETE=true

# If using SSO, set the providers details here. Koel will automatically enable SSO if these values are set.
# Create an OAuth client and get these values from https://console.developers.google.com/apis/credentials
SSO_GOOGLE_CLIENT_ID=
SSO_GOOGLE_CLIENT_SECRET=
# The domain that users must belong to in order to be able to log in.
SSO_GOOGLE_HOSTED_DOMAIN=yourdomain.com


# Sync logs can be found under storage/logs/. Valid options are:
# all: Log everything (errored-, skipped-, and successfully processed file).
# error: Log errors only. This is the default.
Expand Down
6 changes: 6 additions & 0 deletions app/Facades/License.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Facades;

use App\Exceptions\KoelPlusRequiredException;
use Illuminate\Support\Facades\Facade;

/**
Expand All @@ -11,6 +12,11 @@
*/
class License extends Facade
{
public static function requirePlus(): void
{
throw_unless(static::isPlus(), KoelPlusRequiredException::class);
}

protected static function getFacadeAccessor(): string
{
return 'License';
Expand Down
21 changes: 21 additions & 0 deletions app/Helpers.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use App\Facades\License;
use Illuminate\Support\Facades\File as FileFacade;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
Expand Down Expand Up @@ -106,3 +107,23 @@ function mailer_configured(): bool
{
return config('mail.default') && !in_array(config('mail.default'), ['log', 'array'], true);
}

/** @return array<string> */
function collect_sso_providers(): array
{
if (License::isCommunity()) {
return [];
}

$providers = [];

if (
config('services.google.client_id')
&& config('services.google.client_secret')
&& config('services.google.hd')
) {
$providers[] = 'Google';
}

return $providers;
}
5 changes: 3 additions & 2 deletions app/Http/Controllers/API/ProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ public function update(ProfileUpdateRequest $request)
{
static::disableInDemo(Response::HTTP_NO_CONTENT);

throw_unless(
$this->hash->check($request->current_password, $this->user->password),
// If the user is not using SSO, we need to verify their current password.
throw_if(
!$this->user->is_sso && !$this->hash->check($request->current_password, $this->user->password),
ValidationException::withMessages(['current_password' => 'Invalid current password'])
);

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

namespace App\Http\Controllers\SSO;

use App\Facades\License;
use App\Http\Controllers\Controller;
use App\Services\AuthenticationService;
use App\Services\UserService;
use Laravel\Socialite\Facades\Socialite;

class GoogleCallbackController extends Controller
{
public function __invoke(AuthenticationService $auth, UserService $userService)
{
assert(License::isPlus());

$user = Socialite::driver('google')->user();
$user = $userService->createOrUpdateUserFromSocialiteUser($user, 'Google');

return view('sso-callback')->with('token', $auth->logUserIn($user)->toArray());
}
}
14 changes: 12 additions & 2 deletions app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@

use App\Http\Middleware\AudioAuthenticate;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\ForceHttps;
use App\Http\Middleware\ObjectStorageAuthenticate;
use App\Http\Middleware\ThrottleRequests;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;

class Kernel extends HttpKernel
{
Expand All @@ -37,11 +42,16 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
'bindings',
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
ShareErrorsFromSession::class,
StartSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
],
'api' => [
'throttle:60,1',
'bindings',
SubstituteBindings::class,
],
];

Expand Down
2 changes: 1 addition & 1 deletion app/Http/Requests/API/ProfileUpdateRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public function rules(): array
return [
'name' => 'required',
'email' => 'required|email|unique:users,email,' . auth()->user()->id,
'current_password' => 'required',
'current_password' => 'sometimes|required_with:new_password',
'new_password' => ['sometimes', Password::defaults()],
];
}
Expand Down
4 changes: 4 additions & 0 deletions app/Http/Resources/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class UserResource extends JsonResource
'is_admin',
'preferences',
'is_prospect',
'sso_provider',
'sso_id',
];

public function __construct(private User $user)
Expand All @@ -35,6 +37,8 @@ public function toArray($request): array
'is_admin' => $this->user->is_admin,
'preferences' => $this->user->preferences,
'is_prospect' => $this->user->is_prospect,
'sso_provider' => $this->user->sso_provider,
'sso_id' => $this->user->sso_id,
];
}
}
20 changes: 20 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Models;

use App\Casts\UserPreferencesCast;
use App\Facades\License;
use App\Values\UserPreferences;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
Expand All @@ -14,6 +15,7 @@
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Sanctum\PersonalAccessToken;

Expand All @@ -24,6 +26,7 @@
* @property string $name
* @property string $email
* @property string $password
* @property-read bool $has_custom_avatar
* @property-read string $avatar
* @property Collection|array<array-key, Playlist> $playlists
* @property Collection|array<array-key, PlaylistFolder> $playlist_folders
Expand All @@ -34,6 +37,9 @@
* @property ?Carbon $invited_at
* @property-read bool $is_prospect
* @property Collection|array<array-key, Playlist> $collaboratedPlaylists
* @property ?string $sso_provider
* @property ?string $sso_id
* @property bool $is_sso
*/
class User extends Authenticatable
{
Expand Down Expand Up @@ -80,15 +86,29 @@ protected function avatar(): Attribute
return Attribute::get(function (): string {
$avatar = Arr::get($this->attributes, 'avatar');

if (Str::startsWith($avatar, ['http://', 'https://'])) {
return $avatar;
}

return $avatar ? user_avatar_url($avatar) : gravatar($this->email);
});
}

protected function hasCustomAvatar(): Attribute
{
return Attribute::get(fn (): bool => (bool) $this->attributes['avatar']);
}

protected function isProspect(): Attribute
{
return Attribute::get(fn (): bool => (bool) $this->invitation_token);
}

protected function isSso(): Attribute
{
return Attribute::get(fn (): bool => License::isPlus() && $this->sso_provider);
}

/**
* Determine if the user is connected to Last.fm.
*/
Expand Down
14 changes: 13 additions & 1 deletion app/Repositories/UserRepository.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<?php

/** @noinspection PhpIncompatibleReturnTypeInspection */

namespace App\Repositories;

use App\Models\User;
use Laravel\Socialite\Contracts\User as SocialiteUser;

class UserRepository extends Repository
{
Expand All @@ -13,6 +16,15 @@ public function getDefaultAdminUser(): User

public function findOneByEmail(string $email): ?User
{
return User::query()->where('email', $email)->first();
return User::query()->firstWhere('email', $email);
}

public function findOneBySocialiteUser(SocialiteUser $socialiteUser, string $provider): ?User
{
// we prioritize the SSO ID over the email address, but still resort to the latter
return User::query()->firstWhere([
'sso_id' => $socialiteUser->getId(),
'sso_provider' => $provider,
]) ?? $this->findOneByEmail($socialiteUser->getEmail());
}
}
10 changes: 10 additions & 0 deletions app/Services/AuthenticationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public function login(string $email, string $password): CompositeToken
$user->save();
}

return $this->logUserIn($user);
}

public function logUserIn(User $user): CompositeToken
{
return $this->tokenManager->createCompositeToken($user);
}

Expand All @@ -48,6 +53,11 @@ public function trySendResetPasswordLink(string $email): bool
return $this->passwordBroker->sendResetLink(['email' => $email]) === Password::RESET_LINK_SENT;
}

public function generatePasswordResetToken(User $user): string
{
return $this->passwordBroker->createToken($user);
}

public function tryResetPasswordUsingBroker(string $email, string $password, string $token): bool
{
$credentials = [
Expand Down
19 changes: 5 additions & 14 deletions app/Services/PlaylistCollaborationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use App\Events\NewPlaylistCollaboratorJoined;
use App\Exceptions\CannotRemoveOwnerFromPlaylistException;
use App\Exceptions\KoelPlusRequiredException;
use App\Exceptions\NotAPlaylistCollaboratorException;
use App\Exceptions\OperationNotApplicableForSmartPlaylistException;
use App\Exceptions\PlaylistCollaborationTokenExpiredException;
Expand All @@ -18,19 +17,20 @@

class PlaylistCollaborationService
{
public function createToken(Playlist $playlist): PlaylistCollaborationToken
public function __construct()
{
self::assertKoelPlus();
License::requirePlus();
}

public function createToken(Playlist $playlist): PlaylistCollaborationToken
{
throw_if($playlist->is_smart, OperationNotApplicableForSmartPlaylistException::class);

return $playlist->collaborationTokens()->create();
}

public function acceptUsingToken(string $token, User $user): Playlist
{
self::assertKoelPlus();

/** @var PlaylistCollaborationToken $collaborationToken */
$collaborationToken = PlaylistCollaborationToken::query()->where('token', $token)->firstOrFail();

Expand All @@ -52,8 +52,6 @@ public function acceptUsingToken(string $token, User $user): Playlist
/** @return Collection|array<array-key, PlaylistCollaborator> */
public function getCollaborators(Playlist $playlist): Collection
{
self::assertKoelPlus();

return $playlist->collaborators->unless(
$playlist->collaborators->contains($playlist->user), // The owner is always a collaborator
static fn (Collection $collaborators) => $collaborators->push($playlist->user)
Expand All @@ -63,8 +61,6 @@ public function getCollaborators(Playlist $playlist): Collection

public function removeCollaborator(Playlist $playlist, User $user): void
{
self::assertKoelPlus();

throw_if($user->is($playlist->user), CannotRemoveOwnerFromPlaylistException::class);
throw_if(!$playlist->hasCollaborator($user), NotAPlaylistCollaboratorException::class);

Expand All @@ -73,9 +69,4 @@ public function removeCollaborator(Playlist $playlist, User $user): void
$playlist->songs()->wherePivot('user_id', $user->id)->detach();
});
}

private static function assertKoelPlus(): void
{
throw_unless(License::isPlus(), KoelPlusRequiredException::class);
}
}
Loading

0 comments on commit bd8ada1

Please sign in to comment.