feat: support Google SSO

This commit is contained in:
Phan An 2024-03-30 17:49:25 +01:00
parent 58f62c24ed
commit bd8ada1d10
46 changed files with 1099 additions and 88 deletions

View file

@ -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.

View file

@ -2,6 +2,7 @@
namespace App\Facades;
use App\Exceptions\KoelPlusRequiredException;
use Illuminate\Support\Facades\Facade;
/**
@ -11,6 +12,11 @@ use Illuminate\Support\Facades\Facade;
*/
class License extends Facade
{
public static function requirePlus(): void
{
throw_unless(static::isPlus(), KoelPlusRequiredException::class);
}
protected static function getFacadeAccessor(): string
{
return 'License';

View file

@ -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;
@ -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;
}

View file

@ -34,8 +34,9 @@ class ProfileController extends Controller
{
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'])
);

View file

@ -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());
}
}

View file

@ -4,16 +4,21 @@ namespace App\Http;
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
{
@ -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,
],
];

View file

@ -19,7 +19,7 @@ class ProfileUpdateRequest extends Request
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()],
];
}

View file

@ -16,6 +16,8 @@ class UserResource extends JsonResource
'is_admin',
'preferences',
'is_prospect',
'sso_provider',
'sso_id',
];
public function __construct(private User $user)
@ -35,6 +37,8 @@ class UserResource extends JsonResource
'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,
];
}
}

View file

@ -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;
@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
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;
@ -24,6 +26,7 @@ use Laravel\Sanctum\PersonalAccessToken;
* @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
@ -34,6 +37,9 @@ use Laravel\Sanctum\PersonalAccessToken;
* @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
{
@ -80,15 +86,29 @@ class User extends Authenticatable
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.
*/

View file

@ -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
{
@ -13,6 +16,15 @@ class UserRepository extends Repository
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());
}
}

View file

@ -35,6 +35,11 @@ class AuthenticationService
$user->save();
}
return $this->logUserIn($user);
}
public function logUserIn(User $user): CompositeToken
{
return $this->tokenManager->createCompositeToken($user);
}
@ -48,6 +53,11 @@ class AuthenticationService
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 = [

View file

@ -4,7 +4,6 @@ namespace App\Services;
use App\Events\NewPlaylistCollaboratorJoined;
use App\Exceptions\CannotRemoveOwnerFromPlaylistException;
use App\Exceptions\KoelPlusRequiredException;
use App\Exceptions\NotAPlaylistCollaboratorException;
use App\Exceptions\OperationNotApplicableForSmartPlaylistException;
use App\Exceptions\PlaylistCollaborationTokenExpiredException;
@ -18,10 +17,13 @@ use Illuminate\Support\Facades\DB;
class PlaylistCollaborationService
{
public function __construct()
{
License::requirePlus();
}
public function createToken(Playlist $playlist): PlaylistCollaborationToken
{
self::assertKoelPlus();
throw_if($playlist->is_smart, OperationNotApplicableForSmartPlaylistException::class);
return $playlist->collaborationTokens()->create();
@ -29,8 +31,6 @@ class PlaylistCollaborationService
public function acceptUsingToken(string $token, User $user): Playlist
{
self::assertKoelPlus();
/** @var PlaylistCollaborationToken $collaborationToken */
$collaborationToken = PlaylistCollaborationToken::query()->where('token', $token)->firstOrFail();
@ -52,8 +52,6 @@ class PlaylistCollaborationService
/** @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)
@ -63,8 +61,6 @@ class PlaylistCollaborationService
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);
@ -73,9 +69,4 @@ class PlaylistCollaborationService
$playlist->songs()->wherePivot('user_id', $user->id)->detach();
});
}
private static function assertKoelPlus(): void
{
throw_unless(License::isPlus(), KoelPlusRequiredException::class);
}
}

View file

@ -3,69 +3,125 @@
namespace App\Services;
use App\Exceptions\UserProspectUpdateDeniedException;
use App\Facades\License;
use App\Models\User;
use App\Repositories\UserRepository;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Webmozart\Assert\Assert;
class UserService
{
public function __construct(private Hasher $hash, private ImageWriter $imageWriter)
{
public function __construct(
private UserRepository $repository,
private Hasher $hash,
private ImageWriter $imageWriter
) {
}
public function createUser(string $name, string $email, string $plainTextPassword, bool $isAdmin): User
{
/** @noinspection PhpIncompatibleReturnTypeInspection */
public function createUser(
string $name,
string $email,
string $plainTextPassword,
bool $isAdmin,
?string $avatar = null,
?string $ssoId = null,
?string $ssoProvider = null,
): User {
if ($ssoProvider) {
License::requirePlus();
Assert::oneOf($ssoProvider, ['Google']);
}
return User::query()->create([
'name' => $name,
'email' => $email,
'password' => $this->hash->make($plainTextPassword),
'password' => $plainTextPassword ? $this->hash->make($plainTextPassword) : '',
'is_admin' => $isAdmin,
'sso_id' => $ssoId,
'sso_provider' => $ssoProvider,
'avatar' => $avatar ? $this->createNewAvatar($avatar) : null,
]);
}
public function createOrUpdateUserFromSocialiteUser(SocialiteUser $socialiteUser, string $provider): User
{
License::requirePlus();
Assert::oneOf($provider, ['Google']);
$existingUser = $this->repository->findOneBySocialiteUser($socialiteUser, $provider);
if ($existingUser) {
$existingUser->update([
'avatar' => $existingUser->has_custom_avatar ? $existingUser->avatar : $socialiteUser->getAvatar(),
'sso_id' => $socialiteUser->getId(),
'sso_provider' => $provider,
]);
return $existingUser;
}
return $this->createUser(
name: $socialiteUser->getName(),
email: $socialiteUser->getEmail(),
plainTextPassword: '',
isAdmin: false,
avatar: $socialiteUser->getAvatar(),
ssoId: $socialiteUser->getId(),
ssoProvider: $provider
);
}
public function updateUser(
User $user,
string $name,
string $email,
?string $password,
?string $password = null,
?bool $isAdmin = null,
?string $avatar = null
): User {
throw_if($user->is_prospect, new UserProspectUpdateDeniedException());
$data = [
'name' => $name,
'email' => $email,
];
if ($isAdmin !== null) {
$data['is_admin'] = $isAdmin;
}
if ($password) {
$data['password'] = $this->hash->make($password);
}
if ($avatar) {
$oldAvatar = $user->getRawOriginal('avatar');
$path = self::generateUserAvatarPath();
$this->imageWriter->write($path, $avatar, ['max_width' => 480]);
$data['avatar'] = basename($path);
if ($oldAvatar) {
File::delete($oldAvatar);
}
if ($user->sso_provider) {
// An SSO profile is largely managed by the SSO provider
$user->update([
'is_admin' => $isAdmin ?? $user->is_admin,
'name' => $name,
'avatar' => $avatar ? $this->createNewAvatar($avatar, $user) : null,
]);
} else {
$data['avatar'] = null;
$user->update([
'name' => $name,
'email' => $email,
'password' => $password ? $this->hash->make($password) : $user->password,
'is_admin' => $isAdmin ?? $user->is_admin,
'avatar' => $avatar ? $this->createNewAvatar($avatar, $user) : null,
]);
}
$user->update($data);
return $user;
}
/**
* @param string $avatar Either the URL of the avatar or image data
*/
private function createNewAvatar(string $avatar, ?User $user = null): string
{
if (Str::startsWith($avatar, ['http://', 'https://'])) {
return $avatar;
}
$path = self::generateUserAvatarPath();
$this->imageWriter->write($path, $avatar, ['max_width' => 480]);
optional($user?->getRawOriginal('avatar'), static fn (string $oldAvatar) => File::delete($oldAvatar));
return basename($path);
}
public function deleteUser(User $user): void
{
$user->delete();

View file

@ -39,7 +39,8 @@
"league/flysystem-aws-s3-v3": "^3.0",
"spatie/flysystem-dropbox": "^3.0",
"saloonphp/saloon": "^3.8",
"saloonphp/laravel-plugin": "^3.0"
"saloonphp/laravel-plugin": "^3.0",
"laravel/socialite": "^5.12"
},
"require-dev": {
"mockery/mockery": "~1.0",

148
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "05ad56210e2f5ed90da83428f2777175",
"content-hash": "b70ee2f17153e514d8f8a9bef5afa8a8",
"packages": [
{
"name": "algolia/algoliasearch-client-php",
@ -2661,6 +2661,76 @@
},
"time": "2023-01-30T18:31:20+00:00"
},
{
"name": "laravel/socialite",
"version": "v5.12.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
"reference": "7dae1b072573809f32ab6dcf4aebb57c8b3e8acf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/socialite/zipball/7dae1b072573809f32ab6dcf4aebb57c8b3e8acf",
"reference": "7dae1b072573809f32ab6dcf4aebb57c8b3e8acf",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/guzzle": "^6.0|^7.0",
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"league/oauth1-client": "^1.10.1",
"php": "^7.2|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8.0|^9.3|^10.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.x-dev"
},
"laravel": {
"providers": [
"Laravel\\Socialite\\SocialiteServiceProvider"
],
"aliases": {
"Socialite": "Laravel\\Socialite\\Facades\\Socialite"
}
}
},
"autoload": {
"psr-4": {
"Laravel\\Socialite\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.",
"homepage": "https://laravel.com",
"keywords": [
"laravel",
"oauth"
],
"support": {
"issues": "https://github.com/laravel/socialite/issues",
"source": "https://github.com/laravel/socialite"
},
"time": "2024-02-16T08:58:20+00:00"
},
{
"name": "laravel/ui",
"version": "v3.4.6",
@ -3182,6 +3252,82 @@
],
"time": "2024-01-28T23:22:08+00:00"
},
{
"name": "league/oauth1-client",
"version": "v1.10.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth1-client.git",
"reference": "d6365b901b5c287dd41f143033315e2f777e1167"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/d6365b901b5c287dd41f143033315e2f777e1167",
"reference": "d6365b901b5c287dd41f143033315e2f777e1167",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-openssl": "*",
"guzzlehttp/guzzle": "^6.0|^7.0",
"guzzlehttp/psr7": "^1.7|^2.0",
"php": ">=7.1||>=8.0"
},
"require-dev": {
"ext-simplexml": "*",
"friendsofphp/php-cs-fixer": "^2.17",
"mockery/mockery": "^1.3.3",
"phpstan/phpstan": "^0.12.42",
"phpunit/phpunit": "^7.5||9.5"
},
"suggest": {
"ext-simplexml": "For decoding XML-based responses."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev",
"dev-develop": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"League\\OAuth1\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ben Corlett",
"email": "bencorlett@me.com",
"homepage": "http://www.webcomm.com.au",
"role": "Developer"
}
],
"description": "OAuth 1.0 Client Library",
"keywords": [
"Authentication",
"SSO",
"authorization",
"bitbucket",
"identity",
"idp",
"oauth",
"oauth1",
"single sign on",
"trello",
"tumblr",
"twitter"
],
"support": {
"issues": "https://github.com/thephpleague/oauth1-client/issues",
"source": "https://github.com/thephpleague/oauth1-client/tree/v1.10.1"
},
"time": "2022-04-15T14:02:14+00:00"
},
{
"name": "lstrojny/functional-php",
"version": "1.17.0",

View file

@ -33,4 +33,11 @@ return [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
],
'google' => [
'client_id' => env('SSO_GOOGLE_CLIENT_ID'),
'client_secret' => env('SSO_GOOGLE_CLIENT_SECRET'),
'redirect' => '/auth/google/callback',
'hd' => env('SSO_GOOGLE_HOSTED_DOMAIN'),
],
];

View file

@ -30,4 +30,13 @@ class UserFactory extends Factory
{
return $this->state(fn () => ['is_admin' => true]); // @phpcs:ignore
}
public function prospect(): self
{
return $this->state(fn () => [ // @phpcs:ignore
'invitation_token' => Str::random(),
'invited_at' => now(),
'invited_by_id' => User::factory()->admin(),
]);
}
}

View file

@ -0,0 +1,15 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', static function (Blueprint $table): void {
$table->string('sso_provider')->nullable();
});
}
};

View file

@ -0,0 +1,16 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', static function (Blueprint $table): void {
$table->string('sso_id')->nullable()->index();
$table->unique(['sso_provider', 'sso_id']);
});
}
};

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48px" height="48px">
<path
fill="#FFC107"
d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"
/>
<path
fill="#FF3D00"
d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"
/>
<path
fill="#4CAF50"
d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"
/>
<path
fill="#1976D2"
d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -9,7 +9,9 @@ export default (faker: Faker): User => ({
is_prospect: false,
is_admin: false,
avatar: 'https://gravatar.com/foo',
preferences: undefined
preferences: undefined,
sso_provider: null,
sso_id: null
})
export const states: Record<string, Omit<Partial<User>, 'type'>> = {

View file

@ -6,6 +6,7 @@ declare global {
interface Window {
BASE_URL: string
MAILER_CONFIGURED: boolean
SSO_PROVIDERS: string[]
createLemonSqueezy: () => void
}
@ -50,6 +51,7 @@ HTMLDialogElement.prototype.close = vi.fn(function mock () {
window.BASE_URL = 'http://test/'
window.MAILER_CONFIGURED = true
window.SSO_PROVIDERS = []
window.createLemonSqueezy = vi.fn()

View file

@ -49,6 +49,10 @@ const requestResetPasswordLink = async () => {
form {
min-width: 480px;
@media screen and (max-width: 480px) {
min-width: 100%;
}
h1 {
margin-bottom: .75rem;
}
@ -56,13 +60,26 @@ form {
> div {
display: flex;
@media screen and (max-width: 480px) {
flex-direction: column;
gap: 1rem;
}
input {
flex: 1;
border-radius: var(--border-radius-input) 0 0 var(--border-radius-input);
@media screen and (max-width: 480px) {
border-radius: var(--border-radius-input);
}
}
[type=submit] {
border-radius: 0 var(--border-radius-input) var(--border-radius-input) 0;
@media screen and (max-width: 480px) {
border-radius: var(--border-radius-input);
}
}
}
}

View file

@ -47,5 +47,21 @@ new class extends UnitTestCase {
expect(screen.queryByText('Forgot password?')).toBeNull()
window.MAILER_CONFIGURED = true
})
it('shows Google login button', async () => {
window.SSO_PROVIDERS = ['Google']
const { html } = this.render(LoginFrom, {
global: {
stubs: {
GoogleLoginButton: this.stub('google-login-button')
}
}
})
expect(html()).toMatchSnapshot()
window.SSO_PROVIDERS = []
})
}
}

View file

@ -24,17 +24,24 @@
</a>
</form>
<div v-if="ssoProviders.length" v-show="!showingForgotPasswordForm" class="sso">
<GoogleLoginButton v-if="ssoProviders.includes('Google')" @error="onSSOError" @success="onSSOSuccess" />
</div>
<ForgotPasswordForm v-if="showingForgotPasswordForm" @cancel="showingForgotPasswordForm = false" />
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { authService } from '@/services'
import { authService, CompositeToken } from '@/services'
import { logger } from '@/utils'
import { useMessageToaster, useRouter } from '@/composables'
import Btn from '@/components/ui/Btn.vue'
import PasswordField from '@/components/ui/PasswordField.vue'
import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.vue'
import GoogleLoginButton from '@/components/auth/sso/GoogleLoginButton.vue'
const DEMO_ACCOUNT = {
email: 'demo@koel.dev',
@ -42,6 +49,7 @@ const DEMO_ACCOUNT = {
}
const canResetPassword = window.MAILER_CONFIGURED && !window.IS_DEMO
const ssoProviders = window.SSO_PROVIDERS || []
const email = ref(window.IS_DEMO ? DEMO_ACCOUNT.email : '')
const password = ref(window.IS_DEMO ? DEMO_ACCOUNT.password : '')
@ -66,6 +74,16 @@ const login = async () => {
window.setTimeout(() => (failed.value = false), 2000)
}
}
const onSSOError = (error: any) => {
logger.error('SSO error: ', error)
useMessageToaster().toastError('Login failed. Please try again.')
}
const onSSOSuccess = (token: CompositeToken) => {
authService.setTokensUsingCompositeToken(token)
emit('loggedin')
}
</script>
<style lang="scss" scoped>
@ -94,13 +112,23 @@ const login = async () => {
}
.login-wrapper {
@include vertical-center();
min-height: 100vh;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
justify-content: center;
align-items: center;
}
height: 100vh;
.sso {
display: flex;
gap: 1rem;
justify-content: center;
}
form {
width: 280px;
width: 276px;
padding: 1.8rem;
background: rgba(255, 255, 255, .08);
border-radius: .6rem;
@ -125,7 +153,8 @@ form {
font-size: .95rem;
}
@media only screen and (max-width: 414px) {
@media only screen and (max-width: 480px) {
width: 100vw;
border: 0;
background: transparent;
}

View file

@ -44,8 +44,9 @@ const submit = async () => {
try {
loading.value = true
await authService.resetPassword(email.value, password.value, token.value)
toastSuccess('Password updated. Please log in with your new password.')
setTimeout(() => go('/', true), 3000)
toastSuccess('Password set.')
await authService.login(email.value, password.value)
setTimeout(() => go('/', true))
} catch (err: any) {
toastError(err.response?.data?.message || 'Failed to set new password. Please try again.')
} finally {
@ -74,6 +75,11 @@ form {
flex-direction: column;
gap: 1rem;
@media screen and (max-width: 480px) {
width: 100vw;
background: transparent;
}
.help {
display: block;
margin-top: .8rem;

View file

@ -7,5 +7,17 @@ exports[`renders 1`] = `
<div data-v-a2893005="" data-v-0b0f87ea=""><input data-v-a2893005="" type="password" placeholder="Password" required=""><button data-v-a2893005="" type="button"><br data-v-a2893005="" data-testid="Icon" icon="[object Object]"></button></div><button data-v-e368fe26="" data-v-0b0f87ea="" type="submit">Log In</button><a data-v-0b0f87ea="" class="reset-password" role="button"> Forgot password? </a>
</form>
<!--v-if-->
<!--v-if-->
</div>
`;
exports[`shows Google login button 1`] = `
<div data-v-0b0f87ea="" class="login-wrapper">
<form data-v-0b0f87ea="" class="" data-testid="login-form">
<div data-v-0b0f87ea="" class="logo"><img data-v-0b0f87ea="" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><input data-v-0b0f87ea="" autofocus="" placeholder="Email Address" required="" type="email">
<div data-v-a2893005="" data-v-0b0f87ea=""><input data-v-a2893005="" type="password" placeholder="Password" required=""><button data-v-a2893005="" type="button"><br data-v-a2893005="" data-testid="Icon" icon="[object Object]"></button></div><button data-v-e368fe26="" data-v-0b0f87ea="" type="submit">Log In</button><a data-v-0b0f87ea="" class="reset-password" role="button"> Forgot password? </a>
</form>
<div data-v-0b0f87ea="" class="sso"><br data-v-0b0f87ea="" data-testid="google-login-button"></div>
<!--v-if-->
</div>
`;

View file

@ -0,0 +1,34 @@
<template>
<button title="Log in with Google" type="button" @click.prevent="loginWithGoogle">
<img :src="googleLogo" alt="Google Logo" width="32" height="32">
</button>
</template>
<script setup lang="ts">
import googleLogo from '@/../img/logos/google.svg'
import { openPopup } from '@/utils'
const emit = defineEmits<{
(e: 'success', data: any): void
(e: 'error', error: any): void
}>()
const loginWithGoogle = async () => {
try {
window.onmessage = (msg: MessageEvent) => emit('success', msg.data)
openPopup('/auth/google/redirect', 'Google Login', 768, 640, window)
} catch (error: any) {
emit('error', error)
}
}
</script>
<style scoped lang="scss">
button {
opacity: .5;
&:hover {
opacity: 1;
}
}
</style>

View file

@ -126,7 +126,7 @@ form {
width: 100%;
}
@media only screen and (max-width: 414px) {
@media only screen and (max-width: 480px) {
border: 0;
background: transparent;
}

View file

@ -1,8 +1,12 @@
<template>
<form data-testid="update-profile-form" @submit.prevent="update">
<AlertBox v-if="currentUser.sso_provider">
Youre logging in via Single Sign On provided by <strong>{{ currentUser.sso_provider }}</strong>.
You can still update your name and avatar here.
</AlertBox>
<div class="profile form-row">
<div class="left">
<div class="form-row">
<div v-if="!currentUser.sso_provider" class="form-row">
<label>
Current Password
<input
@ -20,7 +24,7 @@
<div class="form-row">
<label>
Name
<input id="inputProfileName" v-model="profile.name" name="name" required type="text" data-testid="name">
<input id="inputProfileName" v-model="profile.name" data-testid="name" name="name" required type="text">
</label>
</div>
@ -28,13 +32,18 @@
<label>
Email Address
<input
id="inputProfileEmail" v-model="profile.email" name="email" required type="email"
id="inputProfileEmail"
v-model="profile.email"
:readonly="currentUser.sso_provider"
data-testid="email"
name="email"
required
type="email"
>
</label>
</div>
<div class="form-row">
<div v-if="!currentUser.sso_provider" class="form-row">
<label>
New Password
<PasswordField
@ -65,14 +74,14 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { userStore } from '@/stores'
import { authService, UpdateCurrentProfileData } from '@/services'
import { logger, parseValidationError } from '@/utils'
import { useDialogBox, useMessageToaster } from '@/composables'
import { useDialogBox, useMessageToaster, useAuthorization } from '@/composables'
import Btn from '@/components/ui/Btn.vue'
import PasswordField from '@/components/ui/PasswordField.vue'
import EditableProfileAvatar from '@/components/profile-preferences/EditableProfileAvatar.vue'
import AlertBox from '@/components/ui/AlertBox.vue'
const { toastSuccess } = useMessageToaster()
const { showErrorDialog } = useDialogBox()
@ -81,11 +90,13 @@ const profile = ref<UpdateCurrentProfileData>({} as UpdateCurrentProfileData)
const isDemo = window.IS_DEMO
const { currentUser } = useAuthorization()
onMounted(() => {
profile.value = {
name: userStore.current.name,
email: userStore.current.email,
avatar: userStore.current.avatar,
name: currentUser.value.name,
email: currentUser.value.email,
avatar: currentUser.value.avatar,
current_password: null
}
})
@ -117,6 +128,10 @@ const update = async () => {
form {
width: 66%;
@media (max-width: 1024px) {
width: 100%;
}
input {
width: 100%;
}
@ -126,8 +141,16 @@ form {
align-items: flex-start;
gap: 2.5rem;
@media (max-width: 1024px) {
flex-direction: column;
}
.left {
width: 50%;
@media (max-width: 1024px) {
width: 100%;
}
}
}
}

View file

@ -0,0 +1,61 @@
<template>
<div class="alert-box" :class="`alert-box-${props.type}`">
<Icon v-if="props.type === 'info' || props.type === 'default'" :icon="faInfoCircle" />
<Icon v-if="props.type === 'danger'" :icon="faExclamationCircle" />
<Icon v-if="props.type === 'success'" :icon="faCheckCircle" />
<Icon v-if="props.type === 'warning'" :icon="faExclamationTriangle" />
<div class="text">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import {
faCheckCircle,
faExclamationCircle,
faExclamationTriangle,
faInfoCircle
} from '@fortawesome/free-solid-svg-icons'
const props = withDefaults(defineProps<{ type?: 'default' | 'info' | 'danger' | 'success' | 'warning' }>(), {
type: 'default'
})
</script>
<style scoped lang="scss">
.alert-box {
padding: 1rem;
border-radius: 5px;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 1rem;
background-color: rgba(255, 255, 255, .1);
// though setting margins in components is not recommended, it's safe to assume that an alert box will be used
// in a context where this margin is desired
margin-bottom: 1.5rem;
.text {
flex: 1;
}
&-info {
background-color: rgb(59 130 246);
}
&-success {
background-color: rgb(16 185 129);
}
&-warning {
background-color: rgb(249 115 22);
}
&-danger {
background-color: rgb(185 28 28);
}
}
</style>

View file

@ -5,6 +5,10 @@
</header>
<main>
<AlertBox v-if="user.sso_provider" type="info">
This user logs in via SSO by {{ user.sso_provider }}.<br>
</AlertBox>
<div class="form-row">
<label>
Name
@ -14,10 +18,17 @@
<div class="form-row">
<label>
Email
<input v-model="updateData.email" name="email" required title="Email" type="email">
<input
v-model="updateData.email"
:readonly="user.sso_provider"
name="email"
required
title="Email"
type="email"
>
</label>
</div>
<div class="form-row">
<div v-if="!user.sso_provider" class="form-row">
<label>
Password
<input
@ -56,6 +67,7 @@ import { useDialogBox, useMessageToaster, useModal, useOverlay } from '@/composa
import Btn from '@/components/ui/Btn.vue'
import TooltipIcon from '@/components/ui/TooltipIcon.vue'
import CheckBox from '@/components/ui/CheckBox.vue'
import AlertBox from '@/components/ui/AlertBox.vue'
const { showOverlay, hideOverlay } = useOverlay()
const { toastSuccess } = useMessageToaster()

View file

@ -7,12 +7,20 @@
<span v-if="user.name" class="name">{{ user.name }}</span>
<span v-else class="name anonymous">Anonymous</span>
<Icon v-if="isCurrentUser" :icon="faCircleCheck" class="you text-highlight" title="This is you!" />
<icon
<Icon
v-if="user.is_admin"
:icon="faShield"
class="is-admin text-blue"
title="User has admin privileges"
/>
<img
v-if="user.sso_provider === 'Google'"
title="Google SSO"
:src="googleLogo"
alt="Google"
width="14"
height="14"
>
</h1>
<p class="email text-secondary">{{ user.email }}</p>
@ -33,6 +41,7 @@
</template>
<script lang="ts" setup>
import googleLogo from '@/../img/logos/google.svg'
import { faCircleCheck, faShield } from '@fortawesome/free-solid-svg-icons'
import { computed, toRefs } from 'vue'
import { userStore } from '@/stores'
@ -114,10 +123,9 @@ const revokeInvite = async () => {
h1 {
font-size: 1rem;
font-weight: var(--font-weight-normal);
> * + * {
margin-left: .5rem
}
display: flex;
align-items: center;
gap: .5rem
}
.actions {

View file

@ -27,13 +27,12 @@ const props = withDefaults(defineProps<{
source?: string | null
config?: {
minWidth: number
maxWidth: number
maxWidth?: number
}
}>(), {
source: null,
config: () => ({
minWidth: 192,
maxWidth: 480
minWidth: 192
})
})

View file

@ -23,10 +23,7 @@ const { get: lsGet, set: lsSet, remove: lsRemove } = useLocalStorage(false) // a
export const authService = {
async login (email: string, password: string) {
const token = await http.post<CompositeToken>('me', { email, password })
this.setAudioToken(token['audio-token'])
this.setApiToken(token.token)
this.setTokensUsingCompositeToken(await http.post<CompositeToken>('me', { email, password }))
},
async logout () {
@ -48,6 +45,11 @@ export const authService = {
setApiToken: (token: string) => lsSet(API_TOKEN_STORAGE_KEY, token),
setTokensUsingCompositeToken (compositeToken: CompositeToken) {
this.setApiToken(compositeToken.token)
this.setAudioToken(compositeToken['audio-token'])
},
destroy: () => {
lsRemove(API_TOKEN_STORAGE_KEY)
lsRemove(AUDIO_TOKEN_STORAGE_KEY)

View file

@ -55,10 +55,13 @@ interface Constructable<T> {
new (...args: any): T
}
type SSOProvider = 'Google' | 'Facebook'
interface Window {
BASE_URL: string
MAILER_CONFIGURED: boolean
IS_DEMO: boolean
SSO_PROVIDERS: SSOProvider[] // not supporting Facebook yet, though
readonly PUSHER_APP_KEY: string
readonly PUSHER_APP_CLUSTER: string
@ -292,6 +295,8 @@ interface User {
password?: string
preferences?: UserPreferences
avatar: string
sso_provider: SSOProvider | null
sso_id: string | null
}
interface Settings {

View file

@ -68,3 +68,9 @@ export const gravatar = (email: string, size = 192) => {
const hash = md5(email.trim().toLowerCase())
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=robohash`
}
export const openPopup = (url: string, name: string, width: number, height: number, parent: Window) => {
const y = parent.top!.outerHeight / 2 + parent.top!.screenY - (height / 2)
const x = parent.top!.outerWidth / 2 + parent.top!.screenX - (width / 2)
return parent.open(url, name, `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${width}, height=${height}, top=${y}, left=${x}`)
}

View file

@ -69,8 +69,8 @@ textarea,
display: block;
}
&[disabled] {
opacity: .5;
&[disabled], &[readonly] {
background: rgba(255, 255, 255, .7);
cursor: not-allowed;
}

View file

@ -36,6 +36,8 @@
window.MAILER_CONFIGURED = @json(mailer_configured());
window.IS_DEMO = @json(config('koel.misc.demo'));
window.SSO_PROVIDERS = @json(collect_sso_providers());
window.PUSHER_APP_KEY = @json(config('broadcasting.connections.pusher.key'));
window.PUSHER_APP_CLUSTER = @json(config('broadcasting.connections.pusher.options.cluster'));
</script>

View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SSO Callback | Koel</title>
<script>
window.opener.postMessage(@json($token), '*')
window.close()
</script>
</head>
</html>

View file

@ -9,8 +9,10 @@ use App\Http\Controllers\Download\DownloadPlaylistController;
use App\Http\Controllers\Download\DownloadSongsController;
use App\Http\Controllers\LastfmController;
use App\Http\Controllers\PlayController;
use App\Http\Controllers\SSO\GoogleCallbackController;
use App\Http\Controllers\ViewSongOnITunesController;
use Illuminate\Support\Facades\Route;
use Laravel\Socialite\Facades\Socialite;
Route::middleware('web')->group(static function (): void {
Route::get('/', static fn () => view('index'));
@ -28,6 +30,9 @@ Route::middleware('web')->group(static function (): void {
}
});
Route::get('auth/google/redirect', static fn () => Socialite::driver('google')->redirect());
Route::get('auth/google/callback', GoogleCallbackController::class);
Route::get('dropbox/authorize', AuthorizeDropboxController::class)->name('dropbox.authorize');
Route::middleware('audio.auth')->group(static function (): void {

View file

@ -0,0 +1,39 @@
<?php
namespace Tests\Feature\KoelPlus;
use Tests\PlusTestCase;
use function Tests\create_user;
use function Tests\read_as_data_url;
use function Tests\test_path;
class ProfileTest extends PlusTestCase
{
public function testUpdateSSOProfile(): void
{
$user = create_user([
'sso_provider' => 'Google',
'sso_id' => '123',
'email' => 'user@koel.dev',
'name' => 'SSO User',
'avatar' => null,
// no current password required for SSO users
]);
self::assertTrue($user->is_sso);
self::assertFalse($user->has_custom_avatar);
$this->putAs('api/me', [
'name' => 'Bruce Dickinson',
'email' => 'bruce@iron.com',
'avatar' => read_as_data_url(test_path('blobs/cover.png')),
], $user)->assertOk();
$user->refresh();
self::assertSame('Bruce Dickinson', $user->name);
self::assertSame('user@koel.dev', $user->email); // email should not be updated
self::assertTrue($user->has_custom_avatar);
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace Tests\Feature\KoelPlus\SSO;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as GoogleUser;
use Mockery;
use Tests\PlusTestCase;
use function Tests\create_user;
class GoogleTest extends PlusTestCase
{
public function testCallbackWithNewUser(): void
{
$googleUser = Mockery::mock(GoogleUser::class, [
'getEmail' => 'bruce@iron.com',
'getName' => 'Bruce Dickinson',
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
'getId' => Str::random(),
]);
Socialite::shouldReceive('driver->user')->andReturn($googleUser);
$response = $this->get('auth/google/callback');
$response->assertOk();
$response->assertViewIs('sso-callback');
$response->assertViewHas('token');
}
public function testCallbackWithExistingEmail(): void
{
create_user(['email' => 'bruce@iron.com']);
$googleUser = Mockery::mock(GoogleUser::class, [
'getEmail' => 'bruce@iron.com',
'getName' => 'Bruce Dickinson',
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
'getId' => Str::random(),
]);
Socialite::shouldReceive('driver->user')->andReturn($googleUser);
$response = $this->get('auth/google/callback');
$response->assertOk();
$response->assertViewIs('sso-callback');
$response->assertViewHas('token');
}
public function testCallbackWithExistingSSOUser(): void
{
create_user([
'sso_provider' => 'Google',
'sso_id' => '123',
'email' => 'bruce@iron.com',
]);
$googleUser = Mockery::mock(GoogleUser::class, [
'getEmail' => 'bruce@iron.com',
'getName' => 'Bruce Dickinson',
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
'getId' => '123',
]);
Socialite::shouldReceive('driver->user')->andReturn($googleUser);
$response = $this->get('auth/google/callback');
$response->assertOk();
$response->assertViewIs('sso-callback');
$response->assertViewHas('token');
}
}

View file

@ -14,6 +14,11 @@ function create_admin(array $attributes = []): User
return User::factory()->admin()->create($attributes);
}
function create_user_prospect(array $attributes = []): User
{
return User::factory()->prospect()->create($attributes);
}
function test_path(string $path = ''): string
{
return base_path('tests' . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR));

View file

@ -0,0 +1,134 @@
<?php
namespace Tests\Integration\KoelPlus\Services;
use App\Models\User;
use App\Services\UserService;
use Illuminate\Support\Facades\Hash;
use Laravel\Socialite\Two\User as SocialiteUser;
use Mockery;
use Tests\PlusTestCase;
use function Tests\create_user;
class UserServiceTest extends PlusTestCase
{
private UserService $service;
public function setUp(): void
{
parent::setUp();
$this->service = app(UserService::class);
}
public function testCreateUserViaSSOProvider(): void
{
$user = $this->service->createUser(
name: 'Bruce Dickinson',
email: 'bruce@dickison.com',
plainTextPassword: '',
isAdmin: false,
avatar: 'https://lh3.googleusercontent.com/a/vatar',
ssoId: '123',
ssoProvider: 'Google'
);
self::assertModelExists($user);
self::assertSame('Google', $user->sso_provider);
self::assertSame('123', $user->sso_id);
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
}
public function testCreateUserFromSocialiteUser(): void
{
self::assertDatabaseMissing(User::class, ['email' => 'bruce@iron.com']);
$socialiteUser = Mockery::mock(SocialiteUser::class, [
'getId' => '123',
'getEmail' => 'bruce@iron.com',
'getName' => 'Bruce Dickinson',
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
]);
$user = $this->service->createOrUpdateUserFromSocialiteUser($socialiteUser, 'Google');
self::assertModelExists($user);
self::assertSame('Google', $user->sso_provider);
self::assertSame('Bruce Dickinson', $user->name);
self::assertSame('bruce@iron.com', $user->email);
self::assertSame('123', $user->sso_id);
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
}
public function testUpdateUserFromSSOId(): void
{
$user = create_user([
'email' => 'bruce@iron.com',
'sso_id' => '123',
'sso_provider' => 'Google',
]);
$socialiteUser = Mockery::mock(SocialiteUser::class, [
'getId' => '123',
'getEmail' => 'steve@iron.com',
'getName' => 'Steve Harris',
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
]);
$this->service->createOrUpdateUserFromSocialiteUser($socialiteUser, 'Google');
$user->refresh();
self::assertSame('Steve Harris', $user->name);
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
self::assertSame('steve@iron.com', $user->email);
self::assertSame('Google', $user->sso_provider);
}
public function testUpdateUserFromSSOEmail(): void
{
$user = create_user(['email' => 'bruce@iron.com']);
$socialiteUser = Mockery::mock(SocialiteUser::class, [
'getId' => '123',
'getEmail' => 'bruce@iron.com',
'getName' => 'Bruce Dickinson',
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
]);
$this->service->createOrUpdateUserFromSocialiteUser($socialiteUser, 'Google');
$user->refresh();
self::assertSame('Bruce Dickinson', $user->name);
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
self::assertSame('Google', $user->sso_provider);
}
public function testUpdateSSOUserCannotChangeProfileDetails(): void
{
$user = create_user([
'email' => 'bruce@iron.com',
'name' => 'Bruce Dickinson',
'avatar' => 'https://lh3.googleusercontent.com/a/vatar',
'sso_provider' => 'Google',
]);
$this->service->updateUser(
user: $user,
name: 'Steve Harris',
email: 'steve@iron.com',
password: 'TheTrooper',
isAdmin: true,
avatar: 'https://lh3.googleusercontent.com/a/vatar/2'
);
$user->refresh();
self::assertSame('Bruce Dickinson', $user->name);
self::assertSame('bruce@iron.com', $user->email);
self::assertFalse(Hash::check('TheTrooper', $user->password));
self::assertTrue($user->is_admin);
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace Tests\Integration\Services;
use App\Exceptions\KoelPlusRequiredException;
use App\Exceptions\UserProspectUpdateDeniedException;
use App\Services\UserService;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
use function Tests\create_admin;
use function Tests\create_user;
use function Tests\create_user_prospect;
use function Tests\read_as_data_url;
use function Tests\test_path;
class UserServiceTest extends TestCase
{
private UserService $service;
public function setUp(): void
{
parent::setUp();
$this->service = app(UserService::class);
}
public function testCreateUser(): void
{
$user = $this->service->createUser(
name: 'Bruce Dickinson',
email: 'bruce@dickison.com',
plainTextPassword: 'FearOfTheDark',
isAdmin: true,
avatar: read_as_data_url(test_path('blobs/cover.png')),
);
self::assertModelExists($user);
self::assertTrue(Hash::check('FearOfTheDark', $user->password));
self::assertTrue($user->is_admin);
self::assertFileExists(user_avatar_path($user->getRawOriginal('avatar')));
}
public function testCreateUserWithEmptyAvatarHasGravatar(): void
{
$user = $this->service->createUser(
name: 'Bruce Dickinson',
email: 'bruce@dickison.com',
plainTextPassword: 'FearOfTheDark',
isAdmin: false
);
self::assertModelExists($user);
self::assertTrue(Hash::check('FearOfTheDark', $user->password));
self::assertFalse($user->is_admin);
self::assertStringStartsWith('https://www.gravatar.com/avatar/', $user->avatar);
}
public function testCreateUserWithNoPassword(): void
{
$user = $this->service->createUser(
name: 'Bruce Dickinson',
email: 'bruce@dickison.com',
plainTextPassword: '',
isAdmin: false
);
self::assertModelExists($user);
self::assertEmpty($user->password);
}
public function testCreateSSOUserRequiresKoelPlus(): void
{
$this->expectException(KoelPlusRequiredException::class);
$this->service->createUser(
name: 'Bruce Dickinson',
email: 'bruce@dickison.com',
plainTextPassword: 'FearOfTheDark',
isAdmin: false,
ssoProvider: 'Google'
);
}
public function testUpdateUser(): void
{
$user = create_user();
$this->service->updateUser(
user: $user,
name: 'Steve Harris',
email: 'steve@iron.com',
password: 'TheTrooper',
isAdmin: true,
avatar: read_as_data_url(test_path('blobs/cover.png'))
);
$user->refresh();
self::assertSame('Steve Harris', $user->name);
self::assertSame('steve@iron.com', $user->email);
self::assertTrue(Hash::check('TheTrooper', $user->password));
self::assertTrue($user->is_admin);
self::assertFileExists(user_avatar_path($user->getRawOriginal('avatar')));
}
public function testUpdateUserWithoutSettingPasswordOrAdminStatus(): void
{
$user = create_admin(['password' => Hash::make('TheTrooper')]);
$this->service->updateUser(
user: $user,
name: 'Steve Harris',
email: 'steve@iron.com'
);
$user->refresh();
self::assertSame('Steve Harris', $user->name);
self::assertSame('steve@iron.com', $user->email);
self::assertTrue(Hash::check('TheTrooper', $user->password));
self::assertTrue($user->is_admin);
}
public function testUpdateProspectUserIsNotAllowed(): void
{
$this->expectException(UserProspectUpdateDeniedException::class);
$this->service->updateUser(
user: create_user_prospect(),
name: 'Steve Harris',
email: 'steve@iron.com'
);
}
}