feat: support reverse proxy authentication

This commit is contained in:
Phan An 2024-03-31 19:19:03 +02:00
parent bd8ada1d10
commit d80a19ba70
19 changed files with 288 additions and 52 deletions

View file

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

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Facades\License;
use App\Services\AuthenticationService;
use App\Services\ProxyAuthService;
use Illuminate\Http\Request;
class IndexController extends Controller
{
public function __invoke(Request $request, ProxyAuthService $proxyAuthService, AuthenticationService $auth)
{
$data = ['token' => 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);
}
}

View file

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

View file

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

View file

@ -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 = [

View file

@ -0,0 +1,37 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Values\SSOUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\IpUtils;
use Throwable;
class ProxyAuthService
{
public function __construct(private UserService $userService)
{
}
public function tryGetProxyAuthenticatedUserFromRequest(Request $request): ?User
{
if (!self::validateProxyIp($request)) {
return null;
}
try {
return $this->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'));
}
}

View file

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

49
app/Values/SSOUser.php Normal file
View file

@ -0,0 +1,49 @@
<?php
namespace App\Values;
use Illuminate\Http\Request;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Webmozart\Assert\Assert;
final class SSOUser
{
private function __construct(
public string $provider,
public string $id,
public string $email,
public string $name,
public ?string $avatar,
) {
self::assertValidProvider($provider);
}
public static function fromSocialite(SocialiteUser $socialiteUser, string $provider): self
{
return new self(
provider: $provider,
id: $socialiteUser->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']);
}
}

View file

@ -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',

View file

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

View file

@ -34,9 +34,9 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { authService, CompositeToken } from '@/services'
import { authService } from '@/services'
import { logger } from '@/utils'
import { useMessageToaster, useRouter } from '@/composables'
import { useMessageToaster } from '@/composables'
import Btn from '@/components/ui/Btn.vue'
import PasswordField from '@/components/ui/PasswordField.vue'

View file

@ -1,7 +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>.
<template v-if="currentUser.sso_provider === 'Reverse Proxy'">
Youre authenticated by a reverse proxy.
</template>
<template v-else>
Youre logging in via Single Sign On provided by <strong>{{ currentUser.sso_provider }}</strong>.
</template>
You can still update your name and avatar here.
</AlertBox>
<div class="profile form-row">

View file

@ -11,11 +11,6 @@ export interface UpdateCurrentProfileData {
new_password?: string
}
export interface CompositeToken {
'audio-token': string
'token': string
}
const API_TOKEN_STORAGE_KEY = 'api-token'
const AUDIO_TOKEN_STORAGE_KEY = 'audio-token'

View file

@ -55,13 +55,19 @@ interface Constructable<T> {
new (...args: any): T
}
type SSOProvider = 'Google' | 'Facebook'
interface CompositeToken {
'audio-token': string
token: string
}
type SSOProvider = 'Google' | 'Reverse Proxy'
interface Window {
BASE_URL: string
MAILER_CONFIGURED: boolean
IS_DEMO: boolean
SSO_PROVIDERS: SSOProvider[] // not supporting Facebook yet, though
SSO_PROVIDERS: SSOProvider[]
AUTH_TOKEN: CompositeToken | null
readonly PUSHER_APP_KEY: string
readonly PUSHER_APP_CLUSTER: string

View file

@ -33,11 +33,8 @@
<script>
window.BASE_URL = @json(asset(''));
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

@ -3,5 +3,10 @@
@section('title', 'Koel')
@push('scripts')
<script>
window.MAILER_CONFIGURED = @json(mailer_configured());
window.SSO_PROVIDERS = @json(collect_sso_providers());
window.AUTH_TOKEN = @json($token);
</script>
@vite(['resources/assets/js/app.ts'])
@endpush

View file

@ -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'));

View file

@ -0,0 +1,95 @@
<?php
namespace Tests\Feature\KoelPlus;
use App\Models\User;
use Laravel\Sanctum\PersonalAccessToken;
use Tests\PlusTestCase;
use function Tests\create_user;
class ProxyAuthTest extends PlusTestCase
{
public function setUp(): void
{
parent::setUp();
config([
'koel.proxy_auth.enabled' => 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'));
}
}

View file

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