diff --git a/.env.example b/.env.example
index 199e4733..e2264357 100644
--- a/.env.example
+++ b/.env.example
@@ -157,6 +157,18 @@ SSO_GOOGLE_CLIENT_SECRET=
SSO_GOOGLE_HOSTED_DOMAIN=yourdomain.com
+# Koel can be configured to authenticate users via a reverse proxy.
+# To enable this feature, set PROXY_AUTH_ENABLED to true and provide the necessary configuration below.
+PROXY_AUTH_ENABLED=false
+# The header name that contains the unique identifer for the user
+PROXY_AUTH_USER_HEADER=remote-user
+# The header name that contains the user's preferred, humanly-readable name
+PROXY_AUTH_PREFERRED_NAME_HEADER=remote-preferred-name
+# A comma-separated list of allowed proxy IPs or CIDRs, for example 10.10.1.0/24 or 2001:0db8:/32
+# If empty, NO requests will be allowed (which means proxy authentication is disabled).
+PROXY_AUTH_ALLOW_LIST=
+
+
# 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.
diff --git a/app/Http/Controllers/IndexController.php b/app/Http/Controllers/IndexController.php
new file mode 100644
index 00000000..6b478976
--- /dev/null
+++ b/app/Http/Controllers/IndexController.php
@@ -0,0 +1,25 @@
+ null];
+
+ if (License::isPlus() && config('koel.proxy_auth.enabled')) {
+ $data['token'] = optional(
+ $proxyAuthService->tryGetProxyAuthenticatedUserFromRequest($request),
+ static fn ($user) => $auth->logUserIn($user)->toArray()
+ );
+ }
+
+ return view('index', $data);
+ }
+}
diff --git a/app/Http/Controllers/SSO/GoogleCallbackController.php b/app/Http/Controllers/SSO/GoogleCallbackController.php
index 205f7748..3873cba1 100644
--- a/app/Http/Controllers/SSO/GoogleCallbackController.php
+++ b/app/Http/Controllers/SSO/GoogleCallbackController.php
@@ -6,6 +6,7 @@ use App\Facades\License;
use App\Http\Controllers\Controller;
use App\Services\AuthenticationService;
use App\Services\UserService;
+use App\Values\SSOUser;
use Laravel\Socialite\Facades\Socialite;
class GoogleCallbackController extends Controller
@@ -15,7 +16,7 @@ class GoogleCallbackController extends Controller
assert(License::isPlus());
$user = Socialite::driver('google')->user();
- $user = $userService->createOrUpdateUserFromSocialiteUser($user, 'Google');
+ $user = $userService->createOrUpdateUserFromSSO(SSOUser::fromSocialite($user, 'Google'));
return view('sso-callback')->with('token', $auth->logUserIn($user)->toArray());
}
diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php
index 9654ce96..1ef84659 100644
--- a/app/Repositories/UserRepository.php
+++ b/app/Repositories/UserRepository.php
@@ -5,7 +5,7 @@
namespace App\Repositories;
use App\Models\User;
-use Laravel\Socialite\Contracts\User as SocialiteUser;
+use App\Values\SSOUser;
class UserRepository extends Repository
{
@@ -19,12 +19,12 @@ class UserRepository extends Repository
return User::query()->firstWhere('email', $email);
}
- public function findOneBySocialiteUser(SocialiteUser $socialiteUser, string $provider): ?User
+ public function findOneBySSO(SSOUser $ssoUser): ?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());
+ 'sso_id' => $ssoUser->id,
+ 'sso_provider' => $ssoUser->provider,
+ ]) ?? $this->findOneByEmail($ssoUser->email);
}
}
diff --git a/app/Services/AuthenticationService.php b/app/Services/AuthenticationService.php
index b0ea664d..955082b6 100644
--- a/app/Services/AuthenticationService.php
+++ b/app/Services/AuthenticationService.php
@@ -53,11 +53,6 @@ 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 = [
diff --git a/app/Services/ProxyAuthService.php b/app/Services/ProxyAuthService.php
new file mode 100644
index 00000000..1029184d
--- /dev/null
+++ b/app/Services/ProxyAuthService.php
@@ -0,0 +1,37 @@
+userService->createOrUpdateUserFromSSO(SSOUser::fromProxyAuthRequest($request));
+ } catch (Throwable $e) {
+ Log::error($e->getMessage(), ['exception' => $e]);
+ }
+
+ return null;
+ }
+
+ private static function validateProxyIp(Request $request): bool
+ {
+ return IpUtils::checkIp($request->ip(), config('koel.proxy_auth.allow_list'));
+ }
+}
diff --git a/app/Services/UserService.php b/app/Services/UserService.php
index 8f221925..cc9a7e10 100644
--- a/app/Services/UserService.php
+++ b/app/Services/UserService.php
@@ -6,11 +6,10 @@ use App\Exceptions\UserProspectUpdateDeniedException;
use App\Facades\License;
use App\Models\User;
use App\Repositories\UserRepository;
+use App\Values\SSOUser;
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
{
@@ -33,7 +32,7 @@ class UserService
): User {
if ($ssoProvider) {
License::requirePlus();
- Assert::oneOf($ssoProvider, ['Google']);
+ SSOUser::assertValidProvider($ssoProvider);
}
return User::query()->create([
@@ -47,31 +46,30 @@ class UserService
]);
}
- public function createOrUpdateUserFromSocialiteUser(SocialiteUser $socialiteUser, string $provider): User
+ public function createOrUpdateUserFromSSO(SSOUser $ssoUser): User
{
License::requirePlus();
- Assert::oneOf($provider, ['Google']);
- $existingUser = $this->repository->findOneBySocialiteUser($socialiteUser, $provider);
+ $existingUser = $this->repository->findOneBySSO($ssoUser);
if ($existingUser) {
$existingUser->update([
- 'avatar' => $existingUser->has_custom_avatar ? $existingUser->avatar : $socialiteUser->getAvatar(),
- 'sso_id' => $socialiteUser->getId(),
- 'sso_provider' => $provider,
+ 'avatar' => $existingUser->has_custom_avatar ? $existingUser->avatar : $ssoUser->avatar,
+ 'sso_id' => $ssoUser->id,
+ 'sso_provider' => $ssoUser->provider,
]);
return $existingUser;
}
return $this->createUser(
- name: $socialiteUser->getName(),
- email: $socialiteUser->getEmail(),
+ name: $ssoUser->name,
+ email: $ssoUser->email,
plainTextPassword: '',
isAdmin: false,
- avatar: $socialiteUser->getAvatar(),
- ssoId: $socialiteUser->getId(),
- ssoProvider: $provider
+ avatar: $ssoUser->avatar,
+ ssoId: $ssoUser->id,
+ ssoProvider: $ssoUser->provider,
);
}
diff --git a/app/Values/SSOUser.php b/app/Values/SSOUser.php
new file mode 100644
index 00000000..e4c4ea89
--- /dev/null
+++ b/app/Values/SSOUser.php
@@ -0,0 +1,49 @@
+getId(),
+ email: $socialiteUser->getEmail(),
+ name: $socialiteUser->getName(),
+ avatar: $socialiteUser->getAvatar(),
+ );
+ }
+
+ public static function fromProxyAuthRequest(Request $request): self
+ {
+ $identifier = $request->header(config('koel.proxy_auth.user_header'));
+
+ return new self(
+ provider: 'Reverse Proxy',
+ id: $identifier,
+ email: "$identifier@reverse-proxy",
+ name: $request->header(config('koel.proxy_auth.preferred_name_header')) ?: $identifier,
+ avatar: null,
+ );
+ }
+
+ public static function assertValidProvider(string $provider): void
+ {
+ Assert::oneOf($provider, ['Google', 'Reverse Proxy']);
+ }
+}
diff --git a/config/koel.php b/config/koel.php
index d450a3ac..386e1b3c 100644
--- a/config/koel.php
+++ b/config/koel.php
@@ -141,6 +141,13 @@ return [
'sync_log_level' => env('SYNC_LOG_LEVEL', 'error'),
+ 'proxy_auth' => [
+ 'enabled' => env('PROXY_AUTH_ENABLED', false),
+ 'user_header' => env('PROXY_AUTH_USER_HEADER', 'remote-user'),
+ 'preferred_name_header' => env('PROXY_AUTH_PREFERRED_NAME_HEADER', 'remote-preferred-name'),
+ 'allow_list' => array_map(static fn ($entry) => trim($entry), explode(',', env('PROXY_AUTH_ALLOW_LIST', ''))),
+ ],
+
'misc' => [
'home_url' => 'https://koel.dev',
'docs_url' => 'https://docs.koel.dev',
diff --git a/resources/assets/js/App.vue b/resources/assets/js/App.vue
index a0e931fa..83716ec6 100644
--- a/resources/assets/js/App.vue
+++ b/resources/assets/js/App.vue
@@ -85,6 +85,12 @@ const onUserLoggedIn = async () => {
}
onMounted(async () => {
+ // If the user is authenticated via a proxy, we have the token in the window object.
+ // Simply forward it to the authService and continue with the normal flow.
+ if (window.AUTH_TOKEN) {
+ authService.setTokensUsingCompositeToken(window.AUTH_TOKEN)
+ }
+
// The app has just been initialized, check if we can get the user data with an already existing token
if (authService.hasApiToken()) {
await init()
diff --git a/resources/assets/js/components/auth/LoginForm.vue b/resources/assets/js/components/auth/LoginForm.vue
index acd8229a..15b73d37 100644
--- a/resources/assets/js/components/auth/LoginForm.vue
+++ b/resources/assets/js/components/auth/LoginForm.vue
@@ -34,9 +34,9 @@
diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php
index dfcfdb24..33db5139 100644
--- a/resources/views/index.blade.php
+++ b/resources/views/index.blade.php
@@ -3,5 +3,10 @@
@section('title', 'Koel')
@push('scripts')
+
@vite(['resources/assets/js/app.ts'])
@endpush
diff --git a/routes/web.base.php b/routes/web.base.php
index 3c799692..62c897d3 100644
--- a/routes/web.base.php
+++ b/routes/web.base.php
@@ -7,6 +7,7 @@ use App\Http\Controllers\Download\DownloadArtistController;
use App\Http\Controllers\Download\DownloadFavoritesController;
use App\Http\Controllers\Download\DownloadPlaylistController;
use App\Http\Controllers\Download\DownloadSongsController;
+use App\Http\Controllers\IndexController;
use App\Http\Controllers\LastfmController;
use App\Http\Controllers\PlayController;
use App\Http\Controllers\SSO\GoogleCallbackController;
@@ -15,7 +16,7 @@ use Illuminate\Support\Facades\Route;
use Laravel\Socialite\Facades\Socialite;
Route::middleware('web')->group(static function (): void {
- Route::get('/', static fn () => view('index'));
+ Route::get('/', IndexController::class);
Route::get('remote', static fn () => view('remote'));
diff --git a/tests/Feature/KoelPlus/ProxyAuthTest.php b/tests/Feature/KoelPlus/ProxyAuthTest.php
new file mode 100644
index 00000000..59d88a23
--- /dev/null
+++ b/tests/Feature/KoelPlus/ProxyAuthTest.php
@@ -0,0 +1,95 @@
+ true,
+ 'koel.proxy_auth.allow_list' => ['192.168.1.0/24'],
+ 'koel.proxy_auth.user_header' => 'remote-user',
+ 'koel.proxy_auth.preferred_name_header' => 'remote-preferred-name',
+ ]);
+ }
+
+ protected function tearDown(): void
+ {
+ config([
+ 'koel.proxy_auth.enabled' => false,
+ 'koel.proxy_auth.allow_list' => [],
+ 'koel.proxy_auth.user_header' => 'remote-user',
+ 'koel.proxy_auth.preferred_name_header' => 'remote-preferred-name',
+ ]);
+
+ parent::tearDown();
+ }
+
+ public function testProxyAuthenticateNewUser(): void
+ {
+ $response = $this->get('/', [
+ 'REMOTE_ADDR' => '192.168.1.127',
+ 'remote-user' => '123456',
+ 'remote-preferred-name' => 'Bruce Dickinson',
+ ]);
+
+ $response->assertOk();
+ $response->assertViewHas('token');
+
+ /** @var array $token */
+ $token = $response->viewData('token');
+
+ self::assertNotNull(PersonalAccessToken::findToken($token['token']));
+
+ self::assertDatabaseHas(User::class, [
+ 'email' => '123456@reverse-proxy',
+ 'name' => 'Bruce Dickinson',
+ 'sso_id' => '123456',
+ 'sso_provider' => 'Reverse Proxy',
+ ]);
+ }
+
+ public function testProxyAuthenticateExistingUser(): void
+ {
+ $user = create_user([
+ 'sso_id' => '123456',
+ 'sso_provider' => 'Reverse Proxy',
+ ]);
+
+ $response = $this->get('/', [
+ 'REMOTE_ADDR' => '192.168.1.127',
+ 'remote-user' => '123456',
+ 'remote-preferred-name' => 'Bruce Dickinson',
+ ]);
+
+ $response->assertOk();
+ $response->assertViewHas('token');
+
+ /** @var array $token */
+ $token = $response->viewData('token');
+
+ self::assertTrue($user->is(PersonalAccessToken::findToken($token['token'])->tokenable));
+ }
+
+ public function testProxyAuthenticateWithDisallowedIp(): void
+ {
+ $response = $this->get('/', [
+ 'REMOTE_ADDR' => '255.168.1.127',
+ 'remote-user' => '123456',
+ 'remote-preferred-name' => 'Bruce Dickinson',
+ ]);
+
+ $response->assertOk();
+
+ self::assertNull($response->viewData('token'));
+ }
+}
diff --git a/tests/Integration/KoelPlus/Services/UserServiceTest.php b/tests/Integration/KoelPlus/Services/UserServiceTest.php
index 426bf60e..6669e418 100644
--- a/tests/Integration/KoelPlus/Services/UserServiceTest.php
+++ b/tests/Integration/KoelPlus/Services/UserServiceTest.php
@@ -4,6 +4,7 @@ namespace Tests\Integration\KoelPlus\Services;
use App\Models\User;
use App\Services\UserService;
+use App\Values\SSOUser;
use Illuminate\Support\Facades\Hash;
use Laravel\Socialite\Two\User as SocialiteUser;
use Mockery;
@@ -40,7 +41,7 @@ class UserServiceTest extends PlusTestCase
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
}
- public function testCreateUserFromSocialiteUser(): void
+ public function testCreateUserFromSSO(): void
{
self::assertDatabaseMissing(User::class, ['email' => 'bruce@iron.com']);
@@ -51,7 +52,7 @@ class UserServiceTest extends PlusTestCase
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
]);
- $user = $this->service->createOrUpdateUserFromSocialiteUser($socialiteUser, 'Google');
+ $user = $this->service->createOrUpdateUserFromSSO(SSOUser::fromSocialite($socialiteUser, 'Google'));
self::assertModelExists($user);
@@ -66,6 +67,7 @@ class UserServiceTest extends PlusTestCase
{
$user = create_user([
'email' => 'bruce@iron.com',
+ 'name' => 'Bruce Dickinson',
'sso_id' => '123',
'sso_provider' => 'Google',
]);
@@ -77,30 +79,34 @@ class UserServiceTest extends PlusTestCase
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
]);
- $this->service->createOrUpdateUserFromSocialiteUser($socialiteUser, 'Google');
+ $this->service->createOrUpdateUserFromSSO(SSOUser::fromSocialite($socialiteUser, 'Google'));
$user->refresh();
- self::assertSame('Steve Harris', $user->name);
+ self::assertSame('Bruce Dickinson', $user->name); // Name should not be updated
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
- self::assertSame('steve@iron.com', $user->email);
+ self::assertSame('bruce@iron.com', $user->email); // Email should not be updated
self::assertSame('Google', $user->sso_provider);
}
public function testUpdateUserFromSSOEmail(): void
{
- $user = create_user(['email' => 'bruce@iron.com']);
+ $user = create_user([
+ 'email' => 'bruce@iron.com',
+ 'name' => 'Bruce Dickinson',
+ ]);
$socialiteUser = Mockery::mock(SocialiteUser::class, [
'getId' => '123',
'getEmail' => 'bruce@iron.com',
- 'getName' => 'Bruce Dickinson',
+ 'getName' => 'Steve Harris',
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
+
]);
- $this->service->createOrUpdateUserFromSocialiteUser($socialiteUser, 'Google');
+ $this->service->createOrUpdateUserFromSSO(SSOUser::fromSocialite($socialiteUser, 'Google'));
$user->refresh();
- self::assertSame('Bruce Dickinson', $user->name);
+ self::assertSame('Bruce Dickinson', $user->name); // Name should not be updated
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
self::assertSame('Google', $user->sso_provider);
}
@@ -110,7 +116,6 @@ class UserServiceTest extends PlusTestCase
$user = create_user([
'email' => 'bruce@iron.com',
'name' => 'Bruce Dickinson',
- 'avatar' => 'https://lh3.googleusercontent.com/a/vatar',
'sso_provider' => 'Google',
]);
@@ -120,15 +125,12 @@ class UserServiceTest extends PlusTestCase
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);
}
}