Merge pull request #4714 from BookStackApp/oidc_logout

OIDC RP-Initiated logout
This commit is contained in:
Dan Brown 2023-12-07 18:00:32 +00:00 committed by GitHub
commit 4c0b7f3123
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 426 additions and 234 deletions

View File

@ -273,6 +273,7 @@ OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false OIDC_REMOVE_FROM_GROUPS=false
OIDC_EXTERNAL_ID_CLAIM=sub OIDC_EXTERNAL_ID_CLAIM=sub
OIDC_END_SESSION_ENDPOINT=false
# Disable default third-party services such as Gravatar and Draw.IO # Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option # Service-specific options will override this option

View File

@ -3,34 +3,26 @@
namespace BookStack\Access\Controllers; namespace BookStack\Access\Controllers;
use BookStack\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Access\SocialAuthService; use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\LoginAttemptEmailNeededException; use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException; use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class LoginController extends Controller class LoginController extends Controller
{ {
use ThrottlesLogins; use ThrottlesLogins;
protected SocialAuthService $socialAuthService; public function __construct(
protected LoginService $loginService; protected SocialDriverManager $socialDriverManager,
protected LoginService $loginService,
/** ) {
* Create a new controller instance.
*/
public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
{
$this->middleware('guest', ['only' => ['getLogin', 'login']]); $this->middleware('guest', ['only' => ['getLogin', 'login']]);
$this->middleware('guard:standard,ldap', ['only' => ['login']]); $this->middleware('guard:standard,ldap', ['only' => ['login']]);
$this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]); $this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
$this->socialAuthService = $socialAuthService;
$this->loginService = $loginService;
} }
/** /**
@ -38,7 +30,7 @@ class LoginController extends Controller
*/ */
public function getLogin(Request $request) public function getLogin(Request $request)
{ {
$socialDrivers = $this->socialAuthService->getActiveDrivers(); $socialDrivers = $this->socialDriverManager->getActive();
$authMethod = config('auth.method'); $authMethod = config('auth.method');
$preventInitiation = $request->get('prevent_auto_init') === 'true'; $preventInitiation = $request->get('prevent_auto_init') === 'true';
@ -52,7 +44,7 @@ class LoginController extends Controller
// Store the previous location for redirect after login // Store the previous location for redirect after login
$this->updateIntendedFromPrevious(); $this->updateIntendedFromPrevious();
if (!$preventInitiation && $this->shouldAutoInitiate()) { if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) {
return view('auth.login-initiate', [ return view('auth.login-initiate', [
'authMethod' => $authMethod, 'authMethod' => $authMethod,
]); ]);
@ -101,15 +93,9 @@ class LoginController extends Controller
/** /**
* Logout user and perform subsequent redirect. * Logout user and perform subsequent redirect.
*/ */
public function logout(Request $request) public function logout()
{ {
Auth::guard()->logout(); return redirect($this->loginService->logout());
$request->session()->invalidate();
$request->session()->regenerateToken();
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
return redirect($redirectUri);
} }
/** /**
@ -200,7 +186,7 @@ class LoginController extends Controller
{ {
// Store the previous location for redirect after login // Store the previous location for redirect after login
$previous = url()->previous(''); $previous = url()->previous('');
$isPreviousFromInstance = (strpos($previous, url('/')) === 0); $isPreviousFromInstance = str_starts_with($previous, url('/'));
if (!$previous || !setting('app-public') || !$isPreviousFromInstance) { if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
return; return;
} }
@ -211,23 +197,11 @@ class LoginController extends Controller
]; ];
foreach ($ignorePrefixList as $ignorePrefix) { foreach ($ignorePrefixList as $ignorePrefix) {
if (strpos($previous, url($ignorePrefix)) === 0) { if (str_starts_with($previous, url($ignorePrefix))) {
return; return;
} }
} }
redirect()->setIntendedUrl($previous); redirect()->setIntendedUrl($previous);
} }
/**
* Check if login auto-initiate should be valid based upon authentication config.
*/
protected function shouldAutoInitiate(): bool
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$authMethod = config('auth.method');
$autoRedirect = config('auth.auto_initiate');
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
}
} }

View File

@ -11,9 +11,6 @@ class OidcController extends Controller
{ {
protected OidcService $oidcService; protected OidcService $oidcService;
/**
* OpenIdController constructor.
*/
public function __construct(OidcService $oidcService) public function __construct(OidcService $oidcService)
{ {
$this->oidcService = $oidcService; $this->oidcService = $oidcService;
@ -63,4 +60,12 @@ class OidcController extends Controller
return redirect()->intended(); return redirect()->intended();
} }
/**
* Log the user out then start the OIDC RP-initiated logout process.
*/
public function logout()
{
return redirect($this->oidcService->logout());
}
} }

View File

@ -4,7 +4,7 @@ namespace BookStack\Access\Controllers;
use BookStack\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Access\RegistrationService; use BookStack\Access\RegistrationService;
use BookStack\Access\SocialAuthService; use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controller; use BookStack\Http\Controller;
@ -15,7 +15,7 @@ use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller class RegisterController extends Controller
{ {
protected SocialAuthService $socialAuthService; protected SocialDriverManager $socialDriverManager;
protected RegistrationService $registrationService; protected RegistrationService $registrationService;
protected LoginService $loginService; protected LoginService $loginService;
@ -23,14 +23,14 @@ class RegisterController extends Controller
* Create a new controller instance. * Create a new controller instance.
*/ */
public function __construct( public function __construct(
SocialAuthService $socialAuthService, SocialDriverManager $socialDriverManager,
RegistrationService $registrationService, RegistrationService $registrationService,
LoginService $loginService LoginService $loginService
) { ) {
$this->middleware('guest'); $this->middleware('guest');
$this->middleware('guard:standard'); $this->middleware('guard:standard');
$this->socialAuthService = $socialAuthService; $this->socialDriverManager = $socialDriverManager;
$this->registrationService = $registrationService; $this->registrationService = $registrationService;
$this->loginService = $loginService; $this->loginService = $loginService;
} }
@ -43,7 +43,7 @@ class RegisterController extends Controller
public function getRegister() public function getRegister()
{ {
$this->registrationService->ensureRegistrationAllowed(); $this->registrationService->ensureRegistrationAllowed();
$socialDrivers = $this->socialAuthService->getActiveDrivers(); $socialDrivers = $this->socialDriverManager->getActive();
return view('auth.register', [ return view('auth.register', [
'socialDrivers' => $socialDrivers, 'socialDrivers' => $socialDrivers,

View File

@ -79,7 +79,7 @@ class SocialController extends Controller
try { try {
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser); return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
} catch (SocialSignInAccountNotUsed $exception) { } catch (SocialSignInAccountNotUsed $exception) {
if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) { if ($this->socialAuthService->drivers()->isAutoRegisterEnabled($socialDriver)) {
return $this->socialRegisterCallback($socialDriver, $socialUser); return $this->socialRegisterCallback($socialDriver, $socialUser);
} }
@ -114,7 +114,7 @@ class SocialController extends Controller
{ {
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser); $socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
$socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser); $socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser);
$emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver); $emailVerified = $this->socialAuthService->drivers()->isAutoConfirmEmailEnabled($socialDriver);
// Create an array of the user data to create a new user instance // Create an array of the user data to create a new user instance
$userData = [ $userData = [

View File

@ -16,13 +16,11 @@ class LoginService
{ {
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted'; protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
protected $mfaSession; public function __construct(
protected $emailConfirmationService; protected MfaSession $mfaSession,
protected EmailConfirmationService $emailConfirmationService,
public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService) protected SocialDriverManager $socialDriverManager,
{ ) {
$this->mfaSession = $mfaSession;
$this->emailConfirmationService = $emailConfirmationService;
} }
/** /**
@ -163,4 +161,33 @@ class LoginService
return $result; return $result;
} }
/**
* Logs the current user out of the application.
* Returns an app post-redirect path.
*/
public function logout(): string
{
auth()->logout();
session()->invalidate();
session()->regenerateToken();
return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
}
/**
* Check if login auto-initiate should be active based upon authentication config.
*/
public function shouldAutoInitiate(): bool
{
$autoRedirect = config('auth.auto_initiate');
if (!$autoRedirect) {
return false;
}
$socialDrivers = $this->socialDriverManager->getActive();
$authMethod = config('auth.method');
return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
}
} }

View File

@ -21,6 +21,7 @@ class OidcProviderSettings
public ?string $redirectUri; public ?string $redirectUri;
public ?string $authorizationEndpoint; public ?string $authorizationEndpoint;
public ?string $tokenEndpoint; public ?string $tokenEndpoint;
public ?string $endSessionEndpoint;
/** /**
* @var string[]|array[] * @var string[]|array[]
@ -132,6 +133,10 @@ class OidcProviderSettings
$discoveredSettings['keys'] = $this->filterKeys($keys); $discoveredSettings['keys'] = $this->filterKeys($keys);
} }
if (!empty($result['end_session_endpoint'])) {
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
}
return $discoveredSettings; return $discoveredSettings;
} }

View File

@ -84,6 +84,7 @@ class OidcService
'redirectUri' => url('/oidc/callback'), 'redirectUri' => url('/oidc/callback'),
'authorizationEndpoint' => $config['authorization_endpoint'], 'authorizationEndpoint' => $config['authorization_endpoint'],
'tokenEndpoint' => $config['token_endpoint'], 'tokenEndpoint' => $config['token_endpoint'],
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
]); ]);
// Use keys if configured // Use keys if configured
@ -100,6 +101,14 @@ class OidcService
} }
} }
// Prevent use of RP-initiated logout if specifically disabled
// Or force use of a URL if specifically set.
if ($config['end_session_endpoint'] === false) {
$settings->endSessionEndpoint = null;
} else if (is_string($config['end_session_endpoint'])) {
$settings->endSessionEndpoint = $config['end_session_endpoint'];
}
$settings->validate(); $settings->validate();
return $settings; return $settings;
@ -217,6 +226,8 @@ class OidcService
$settings->keys, $settings->keys,
); );
session()->put("oidc_id_token", $idTokenText);
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [ $returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
'access_token' => $accessToken->getToken(), 'access_token' => $accessToken->getToken(),
'expires_in' => $accessToken->getExpires(), 'expires_in' => $accessToken->getExpires(),
@ -284,4 +295,30 @@ class OidcService
{ {
return $this->config()['user_to_groups'] !== false; return $this->config()['user_to_groups'] !== false;
} }
/**
* Start the RP-initiated logout flow if active, otherwise start a standard logout flow.
* Returns a post-app-logout redirect URL.
* Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
* @throws OidcException
*/
public function logout(): string
{
$oidcToken = session()->pull("oidc_id_token");
$defaultLogoutUrl = url($this->loginService->logout());
$oidcSettings = $this->getProviderSettings();
if (!$oidcSettings->endSessionEndpoint) {
return $defaultLogoutUrl;
}
$endpointParams = [
'id_token_hint' => $oidcToken,
'post_logout_redirect_uri' => $defaultLogoutUrl,
];
$joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?';
return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams);
}
} }

View File

@ -71,8 +71,7 @@ class Saml2Service
throw $error; throw $error;
} }
$this->actionLogout(); $url = $this->loginService->logout();
$url = '/';
$id = null; $id = null;
} }
@ -140,20 +139,11 @@ class Saml2Service
); );
} }
$this->actionLogout(); $this->loginService->logout();
return $redirect; return $redirect;
} }
/**
* Do the required actions to log a user out.
*/
protected function actionLogout()
{
auth()->logout();
session()->invalidate();
}
/** /**
* Get the metadata for this service provider. * Get the metadata for this service provider.
* *

View File

@ -2,69 +2,24 @@
namespace BookStack\Access; namespace BookStack\Access;
use BookStack\Auth\Access\handler;
use BookStack\Exceptions\SocialDriverNotConfigured; use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed; use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite; use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider; use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser; use Laravel\Socialite\Contracts\User as SocialUser;
use Laravel\Socialite\Two\GoogleProvider; use Laravel\Socialite\Two\GoogleProvider;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
class SocialAuthService class SocialAuthService
{ {
/** public function __construct(
* The core socialite library used. protected Socialite $socialite,
* protected LoginService $loginService,
* @var Socialite protected SocialDriverManager $driverManager,
*/ ) {
protected $socialite;
/**
* @var LoginService
*/
protected $loginService;
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected $validSocialDrivers = [
'google',
'github',
'facebook',
'slack',
'twitter',
'azure',
'okta',
'gitlab',
'twitch',
'discord',
];
/**
* Callbacks to run when configuring a social driver
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected $configureForRedirectCallbacks = [];
/**
* SocialAuthService constructor.
*/
public function __construct(Socialite $socialite, LoginService $loginService)
{
$this->socialite = $socialite;
$this->loginService = $loginService;
} }
/** /**
@ -74,9 +29,10 @@ class SocialAuthService
*/ */
public function startLogIn(string $socialDriver): RedirectResponse public function startLogIn(string $socialDriver): RedirectResponse
{ {
$driver = $this->validateDriver($socialDriver); $socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->getDriverForRedirect($driver)->redirect(); return $this->getDriverForRedirect($socialDriver)->redirect();
} }
/** /**
@ -86,9 +42,10 @@ class SocialAuthService
*/ */
public function startRegister(string $socialDriver): RedirectResponse public function startRegister(string $socialDriver): RedirectResponse
{ {
$driver = $this->validateDriver($socialDriver); $socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->getDriverForRedirect($driver)->redirect(); return $this->getDriverForRedirect($socialDriver)->redirect();
} }
/** /**
@ -119,9 +76,10 @@ class SocialAuthService
*/ */
public function getSocialUser(string $socialDriver): SocialUser public function getSocialUser(string $socialDriver): SocialUser
{ {
$driver = $this->validateDriver($socialDriver); $socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->socialite->driver($driver)->user(); return $this->socialite->driver($socialDriver)->user();
} }
/** /**
@ -131,6 +89,7 @@ class SocialAuthService
*/ */
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser) public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
{ {
$socialDriver = trim(strtolower($socialDriver));
$socialId = $socialUser->getId(); $socialId = $socialUser->getId();
// Get any attached social accounts or users // Get any attached social accounts or users
@ -181,76 +140,11 @@ class SocialAuthService
} }
/** /**
* Ensure the social driver is correct and supported. * Get the social driver manager used by this service.
*
* @throws SocialDriverNotConfigured
*/ */
protected function validateDriver(string $socialDriver): string public function drivers(): SocialDriverManager
{ {
$driver = trim(strtolower($socialDriver)); return $this->driverManager;
if (!in_array($driver, $this->validSocialDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driver)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
}
return $driver;
}
/**
* Check a social driver has been configured correctly.
*/
protected function checkDriverConfigured(string $driver): bool
{
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
return !in_array(false, $config) && !in_array(null, $config);
}
/**
* Gets the names of the active social drivers.
* @returns array<string, string>
*/
public function getActiveDrivers(): array
{
$activeDrivers = [];
foreach ($this->validSocialDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the presentational name for a driver.
*/
public function getDriverName(string $driver): string
{
return config('services.' . strtolower($driver) . '.name');
}
/**
* Check if the current config for the given driver allows auto-registration.
*/
public function driverAutoRegisterEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
*/
public function driverAutoConfirmEmailEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
} }
/** /**
@ -284,33 +178,8 @@ class SocialAuthService
$driver->with(['prompt' => 'select_account']); $driver->with(['prompt' => 'select_account']);
} }
if (isset($this->configureForRedirectCallbacks[$driverName])) { $this->driverManager->getConfigureForRedirectCallback($driverName)($driver);
$this->configureForRedirectCallbacks[$driverName]($driver);
}
return $driver; return $driver;
} }
/**
* Add a custom socialite driver to be used.
* Driver name should be lower_snake_case.
* Config array should mirror the structure of a service
* within the `Config/services.php` file.
* Handler should be a Class@method handler to the SocialiteWasCalled event.
*/
public function addSocialDriver(
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
) {
$this->validSocialDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
if (!is_null($configureForRedirect)) {
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
}
}
} }

View File

@ -0,0 +1,147 @@
<?php
namespace BookStack\Access;
use BookStack\Exceptions\SocialDriverNotConfigured;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use SocialiteProviders\Manager\SocialiteWasCalled;
class SocialDriverManager
{
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected array $validDrivers = [
'google',
'github',
'facebook',
'slack',
'twitter',
'azure',
'okta',
'gitlab',
'twitch',
'discord',
];
/**
* Callbacks to run when configuring a social driver
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected array $configureForRedirectCallbacks = [];
/**
* Check if the current config for the given driver allows auto-registration.
*/
public function isAutoRegisterEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
*/
public function isAutoConfirmEmailEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
}
/**
* Gets the names of the active social drivers, keyed by driver id.
* @returns array<string, string>
*/
public function getActive(): array
{
$activeDrivers = [];
foreach ($this->validDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the configure-for-redirect callback for the given driver.
* This is a callable that allows modification of the driver at redirect time.
* Commonly used to perform custom dynamic configuration where required.
* The callback is passed a \Laravel\Socialite\Contracts\Provider instance.
*/
public function getConfigureForRedirectCallback(string $driver): callable
{
return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true);
}
/**
* Add a custom socialite driver to be used.
* Driver name should be lower_snake_case.
* Config array should mirror the structure of a service
* within the `Config/services.php` file.
* Handler should be a Class@method handler to the SocialiteWasCalled event.
*/
public function addSocialDriver(
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
) {
$this->validDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
if (!is_null($configureForRedirect)) {
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
}
}
/**
* Get the presentational name for a driver.
*/
protected function getName(string $driver): string
{
return $this->getDriverConfigProperty($driver, 'name') ?? '';
}
protected function getDriverConfigProperty(string $driver, string $property): mixed
{
return config("services.{$driver}.{$property}");
}
/**
* Ensure the social driver is correct and supported.
*
* @throws SocialDriverNotConfigured
*/
public function ensureDriverActive(string $driverName): void
{
if (!in_array($driverName, $this->validDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driverName)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)]));
}
}
/**
* Check a social driver has been configured correctly.
*/
protected function checkDriverConfigured(string $driver): bool
{
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
return !in_array(false, $config) && !in_array(null, $config);
}
}

View File

@ -2,7 +2,7 @@
namespace BookStack\App\Providers; namespace BookStack\App\Providers;
use BookStack\Access\SocialAuthService; use BookStack\Access\SocialDriverManager;
use BookStack\Activity\Tools\ActivityLogger; use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
@ -36,7 +36,7 @@ class AppServiceProvider extends ServiceProvider
public $singletons = [ public $singletons = [
'activity' => ActivityLogger::class, 'activity' => ActivityLogger::class,
SettingService::class => SettingService::class, SettingService::class => SettingService::class,
SocialAuthService::class => SocialAuthService::class, SocialDriverManager::class => SocialDriverManager::class,
CspService::class => CspService::class, CspService::class => CspService::class,
HttpRequestService::class => HttpRequestService::class, HttpRequestService::class => HttpRequestService::class,
]; ];

View File

@ -36,6 +36,12 @@ return [
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null), 'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
// OIDC RP-Initiated Logout endpoint URL.
// A false value force-disables RP-Initiated Logout.
// A true value gets the URL from discovery, if active.
// A string value is used as the URL.
'end_session_endpoint' => env('OIDC_END_SESSION_ENDPOINT', false),
// Add extra scopes, upon those required, to the OIDC authentication request // Add extra scopes, upon those required, to the OIDC authentication request
// Multiple values can be provided comma seperated. // Multiple values can be provided comma seperated.
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null), 'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
@ -45,6 +51,6 @@ return [
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false), 'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
// Attribute, within a OIDC ID token, to find group names within // Attribute, within a OIDC ID token, to find group names within
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'), 'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups. // When syncing groups, remove any groups that no longer match. Otherwise, sync only adds new groups.
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false), 'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
]; ];

View File

@ -2,7 +2,7 @@
namespace BookStack\Theming; namespace BookStack\Theming;
use BookStack\Access\SocialAuthService; use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\ThemeException; use BookStack\Exceptions\ThemeException;
use Illuminate\Console\Application; use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan; use Illuminate\Console\Application as Artisan;
@ -82,11 +82,11 @@ class ThemeService
} }
/** /**
* @see SocialAuthService::addSocialDriver * @see SocialDriverManager::addSocialDriver
*/ */
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null): void public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null): void
{ {
$socialAuthService = app()->make(SocialAuthService::class); $driverManager = app()->make(SocialDriverManager::class);
$socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect); $driverManager->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
} }
} }

View File

@ -2,7 +2,7 @@
namespace BookStack\Users\Controllers; namespace BookStack\Users\Controllers;
use BookStack\Access\SocialAuthService; use BookStack\Access\SocialDriverManager;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences; use BookStack\Settings\UserNotificationPreferences;
@ -161,7 +161,7 @@ class UserAccountController extends Controller
/** /**
* Show the view for the "Access & Security" account options. * Show the view for the "Access & Security" account options.
*/ */
public function showAuth(SocialAuthService $socialAuthService) public function showAuth(SocialDriverManager $socialDriverManager)
{ {
$mfaMethods = user()->mfaValues()->get()->groupBy('method'); $mfaMethods = user()->mfaValues()->get()->groupBy('method');
@ -171,7 +171,7 @@ class UserAccountController extends Controller
'category' => 'auth', 'category' => 'auth',
'mfaMethods' => $mfaMethods, 'mfaMethods' => $mfaMethods,
'authMethod' => config('auth.method'), 'authMethod' => config('auth.method'),
'activeSocialDrivers' => $socialAuthService->getActiveDrivers(), 'activeSocialDrivers' => $socialDriverManager->getActive(),
]); ]);
} }

View File

@ -2,7 +2,7 @@
namespace BookStack\Users\Controllers; namespace BookStack\Users\Controllers;
use BookStack\Access\SocialAuthService; use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\UserUpdateException; use BookStack\Exceptions\UserUpdateException;
use BookStack\Http\Controller; use BookStack\Http\Controller;
@ -101,7 +101,7 @@ class UserController extends Controller
/** /**
* Show the form for editing the specified user. * Show the form for editing the specified user.
*/ */
public function edit(int $id, SocialAuthService $socialAuthService) public function edit(int $id, SocialDriverManager $socialDriverManager)
{ {
$this->checkPermission('users-manage'); $this->checkPermission('users-manage');
@ -109,7 +109,7 @@ class UserController extends Controller
$user->load(['apiTokens', 'mfaValues']); $user->load(['apiTokens', 'mfaValues']);
$authMethod = ($user->system_name) ? 'system' : config('auth.method'); $authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers(); $activeSocialDrivers = $socialDriverManager->getActive();
$mfaMethods = $user->mfaValues->groupBy('method'); $mfaMethods = $user->mfaValues->groupBy('method');
$this->setPageTitle(trans('settings.user_profile')); $this->setPageTitle(trans('settings.user_profile'));
$roles = Role::query()->orderBy('display_name', 'asc')->get(); $roles = Role::query()->orderBy('display_name', 'asc')->get();

View File

@ -29,8 +29,14 @@
</li> </li>
<li><hr></li> <li><hr></li>
<li> <li>
<form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}" @php
method="post"> $logoutPath = match (config('auth.method')) {
'saml2' => '/saml2/logout',
'oidc' => '/oidc/logout',
default => '/logout',
}
@endphp
<form action="{{ url($logoutPath) }}" method="post">
{{ csrf_field() }} {{ csrf_field() }}
<button class="icon-item" data-shortcut="logout"> <button class="icon-item" data-shortcut="logout">
@icon('logout') @icon('logout')

View File

@ -332,6 +332,7 @@ Route::get('/saml2/acs', [AccessControllers\Saml2Controller::class, 'processAcs'
// OIDC routes // OIDC routes
Route::post('/oidc/login', [AccessControllers\OidcController::class, 'login']); Route::post('/oidc/login', [AccessControllers\OidcController::class, 'login']);
Route::get('/oidc/callback', [AccessControllers\OidcController::class, 'callback']); Route::get('/oidc/callback', [AccessControllers\OidcController::class, 'callback']);
Route::post('/oidc/logout', [AccessControllers\OidcController::class, 'logout']);
// User invitation routes // User invitation routes
Route::get('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'showSetPassword']); Route::get('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'showSetPassword']);

View File

@ -44,6 +44,7 @@ class OidcTest extends TestCase
'oidc.groups_claim' => 'group', 'oidc.groups_claim' => 'group',
'oidc.remove_from_groups' => false, 'oidc.remove_from_groups' => false,
'oidc.external_id_claim' => 'sub', 'oidc.external_id_claim' => 'sub',
'oidc.end_session_endpoint' => false,
]); ]);
} }
@ -478,6 +479,128 @@ class OidcTest extends TestCase
$this->assertTrue($user->hasRole($roleA->id)); $this->assertTrue($user->hasRole($roleA->id));
} }
public function test_oidc_logout_form_active_when_oidc_active()
{
$this->runLogin();
$resp = $this->get('/');
$this->withHtml($resp)->assertElementExists('header form[action$="/oidc/logout"] button');
}
public function test_logout_with_autodiscovery_with_oidc_logout_enabled()
{
config()->set(['oidc.end_session_endpoint' => true]);
$this->withAutodiscovery();
$transactions = $this->mockHttpClient([
$this->getAutoDiscoveryResponse(),
$this->getJwksResponse(),
]);
$resp = $this->asEditor()->post('/oidc/logout');
$resp->assertRedirect('https://auth.example.com/oidc/logout?post_logout_redirect_uri=' . urlencode(url('/')));
$this->assertEquals(2, $transactions->requestCount());
$this->assertFalse(auth()->check());
}
public function test_logout_with_autodiscovery_with_oidc_logout_disabled()
{
$this->withAutodiscovery();
config()->set(['oidc.end_session_endpoint' => false]);
$this->mockHttpClient([
$this->getAutoDiscoveryResponse(),
$this->getJwksResponse(),
]);
$resp = $this->asEditor()->post('/oidc/logout');
$resp->assertRedirect('/');
$this->assertFalse(auth()->check());
}
public function test_logout_without_autodiscovery_but_with_endpoint_configured()
{
config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout']);
$resp = $this->asEditor()->post('/oidc/logout');
$resp->assertRedirect('https://example.com/logout?post_logout_redirect_uri=' . urlencode(url('/')));
$this->assertFalse(auth()->check());
}
public function test_logout_without_autodiscovery_with_configured_endpoint_adds_to_query_if_existing()
{
config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout?a=b']);
$resp = $this->asEditor()->post('/oidc/logout');
$resp->assertRedirect('https://example.com/logout?a=b&post_logout_redirect_uri=' . urlencode(url('/')));
$this->assertFalse(auth()->check());
}
public function test_logout_with_autodiscovery_and_auto_initiate_returns_to_auto_prevented_login()
{
$this->withAutodiscovery();
config()->set([
'auth.auto_initiate' => true,
'services.google.client_id' => false,
'services.github.client_id' => false,
'oidc.end_session_endpoint' => true,
]);
$this->mockHttpClient([
$this->getAutoDiscoveryResponse(),
$this->getJwksResponse(),
]);
$resp = $this->asEditor()->post('/oidc/logout');
$redirectUrl = url('/login?prevent_auto_init=true');
$resp->assertRedirect('https://auth.example.com/oidc/logout?post_logout_redirect_uri=' . urlencode($redirectUrl));
$this->assertFalse(auth()->check());
}
public function test_logout_endpoint_url_overrides_autodiscovery_endpoint()
{
config()->set(['oidc.end_session_endpoint' => 'https://a.example.com']);
$this->withAutodiscovery();
$transactions = $this->mockHttpClient([
$this->getAutoDiscoveryResponse(),
$this->getJwksResponse(),
]);
$resp = $this->asEditor()->post('/oidc/logout');
$resp->assertRedirect('https://a.example.com?post_logout_redirect_uri=' . urlencode(url('/')));
$this->assertEquals(2, $transactions->requestCount());
$this->assertFalse(auth()->check());
}
public function test_logout_with_autodiscovery_does_not_use_rp_logout_if_no_url_via_autodiscovery()
{
config()->set(['oidc.end_session_endpoint' => true]);
$this->withAutodiscovery();
$this->mockHttpClient([
$this->getAutoDiscoveryResponse(['end_session_endpoint' => null]),
$this->getJwksResponse(),
]);
$resp = $this->asEditor()->post('/oidc/logout');
$resp->assertRedirect('/');
$this->assertFalse(auth()->check());
}
public function test_logout_redirect_contains_id_token_hint_if_existing()
{
config()->set(['oidc.end_session_endpoint' => 'https://example.com/logout']);
$this->runLogin();
$resp = $this->asEditor()->post('/oidc/logout');
$query = 'id_token_hint=' . urlencode(OidcJwtHelper::idToken()) . '&post_logout_redirect_uri=' . urlencode(url('/'));
$resp->assertRedirect('https://example.com/logout?' . $query);
}
public function test_oidc_id_token_pre_validate_theme_event_without_return() public function test_oidc_id_token_pre_validate_theme_event_without_return()
{ {
$args = []; $args = [];
@ -563,6 +686,7 @@ class OidcTest extends TestCase
'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
'issuer' => OidcJwtHelper::defaultIssuer(), 'issuer' => OidcJwtHelper::defaultIssuer(),
'end_session_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/logout',
], $responseOverrides))); ], $responseOverrides)));
} }

View File

@ -2,7 +2,7 @@
namespace Tests; namespace Tests;
use BookStack\Access\SocialAuthService; use BookStack\Access\SocialDriverManager;
use Illuminate\Testing\TestResponse; use Illuminate\Testing\TestResponse;
class DebugViewTest extends TestCase class DebugViewTest extends TestCase
@ -46,8 +46,8 @@ class DebugViewTest extends TestCase
protected function getDebugViewForException(\Exception $exception): TestResponse protected function getDebugViewForException(\Exception $exception): TestResponse
{ {
// Fake an error via social auth service used on login page // Fake an error via social auth service used on login page
$mockService = $this->mock(SocialAuthService::class); $mockService = $this->mock(SocialDriverManager::class);
$mockService->shouldReceive('getActiveDrivers')->andThrow($exception); $mockService->shouldReceive('getActive')->andThrow($exception);
return $this->get('/login'); return $this->get('/login');
} }