mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-23 11:22:33 +01:00
Merge pull request #4714 from BookStackApp/oidc_logout
OIDC RP-Initiated logout
This commit is contained in:
commit
4c0b7f3123
@ -273,6 +273,7 @@ OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
OIDC_EXTERNAL_ID_CLAIM=sub
|
||||
OIDC_END_SESSION_ENDPOINT=false
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
|
@ -3,34 +3,26 @@
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
use ThrottlesLogins;
|
||||
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
|
||||
{
|
||||
public function __construct(
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
protected LoginService $loginService,
|
||||
) {
|
||||
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
|
||||
$this->middleware('guard:standard,ldap', ['only' => ['login']]);
|
||||
$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)
|
||||
{
|
||||
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
$preventInitiation = $request->get('prevent_auto_init') === 'true';
|
||||
|
||||
@ -52,7 +44,7 @@ class LoginController extends Controller
|
||||
// Store the previous location for redirect after login
|
||||
$this->updateIntendedFromPrevious();
|
||||
|
||||
if (!$preventInitiation && $this->shouldAutoInitiate()) {
|
||||
if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) {
|
||||
return view('auth.login-initiate', [
|
||||
'authMethod' => $authMethod,
|
||||
]);
|
||||
@ -101,15 +93,9 @@ class LoginController extends Controller
|
||||
/**
|
||||
* Logout user and perform subsequent redirect.
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
public function logout()
|
||||
{
|
||||
Auth::guard()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
|
||||
return redirect($redirectUri);
|
||||
return redirect($this->loginService->logout());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -200,7 +186,7 @@ class LoginController extends Controller
|
||||
{
|
||||
// Store the previous location for redirect after login
|
||||
$previous = url()->previous('');
|
||||
$isPreviousFromInstance = (strpos($previous, url('/')) === 0);
|
||||
$isPreviousFromInstance = str_starts_with($previous, url('/'));
|
||||
if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
|
||||
return;
|
||||
}
|
||||
@ -211,23 +197,11 @@ class LoginController extends Controller
|
||||
];
|
||||
|
||||
foreach ($ignorePrefixList as $ignorePrefix) {
|
||||
if (strpos($previous, url($ignorePrefix)) === 0) {
|
||||
if (str_starts_with($previous, url($ignorePrefix))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
@ -11,9 +11,6 @@ class OidcController extends Controller
|
||||
{
|
||||
protected OidcService $oidcService;
|
||||
|
||||
/**
|
||||
* OpenIdController constructor.
|
||||
*/
|
||||
public function __construct(OidcService $oidcService)
|
||||
{
|
||||
$this->oidcService = $oidcService;
|
||||
@ -63,4 +60,12 @@ class OidcController extends Controller
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out then start the OIDC RP-initiated logout process.
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
return redirect($this->oidcService->logout());
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\RegistrationService;
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Http\Controller;
|
||||
@ -15,7 +15,7 @@ use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected SocialDriverManager $socialDriverManager;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
@ -23,14 +23,14 @@ class RegisterController extends Controller
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(
|
||||
SocialAuthService $socialAuthService,
|
||||
SocialDriverManager $socialDriverManager,
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService
|
||||
) {
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->socialDriverManager = $socialDriverManager;
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
@ -43,7 +43,7 @@ class RegisterController extends Controller
|
||||
public function getRegister()
|
||||
{
|
||||
$this->registrationService->ensureRegistrationAllowed();
|
||||
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
|
||||
return view('auth.register', [
|
||||
'socialDrivers' => $socialDrivers,
|
||||
|
@ -79,7 +79,7 @@ class SocialController extends Controller
|
||||
try {
|
||||
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
|
||||
} catch (SocialSignInAccountNotUsed $exception) {
|
||||
if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
|
||||
if ($this->socialAuthService->drivers()->isAutoRegisterEnabled($socialDriver)) {
|
||||
return $this->socialRegisterCallback($socialDriver, $socialUser);
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ class SocialController extends Controller
|
||||
{
|
||||
$socialUser = $this->socialAuthService->handleRegistrationCallback($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
|
||||
$userData = [
|
||||
|
@ -16,13 +16,11 @@ class LoginService
|
||||
{
|
||||
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
|
||||
|
||||
protected $mfaSession;
|
||||
protected $emailConfirmationService;
|
||||
|
||||
public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
|
||||
{
|
||||
$this->mfaSession = $mfaSession;
|
||||
$this->emailConfirmationService = $emailConfirmationService;
|
||||
public function __construct(
|
||||
protected MfaSession $mfaSession,
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -163,4 +161,33 @@ class LoginService
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ class OidcProviderSettings
|
||||
public ?string $redirectUri;
|
||||
public ?string $authorizationEndpoint;
|
||||
public ?string $tokenEndpoint;
|
||||
public ?string $endSessionEndpoint;
|
||||
|
||||
/**
|
||||
* @var string[]|array[]
|
||||
@ -132,6 +133,10 @@ class OidcProviderSettings
|
||||
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
||||
}
|
||||
|
||||
if (!empty($result['end_session_endpoint'])) {
|
||||
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
|
||||
}
|
||||
|
||||
return $discoveredSettings;
|
||||
}
|
||||
|
||||
|
@ -84,6 +84,7 @@ class OidcService
|
||||
'redirectUri' => url('/oidc/callback'),
|
||||
'authorizationEndpoint' => $config['authorization_endpoint'],
|
||||
'tokenEndpoint' => $config['token_endpoint'],
|
||||
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
|
||||
]);
|
||||
|
||||
// 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();
|
||||
|
||||
return $settings;
|
||||
@ -217,6 +226,8 @@ class OidcService
|
||||
$settings->keys,
|
||||
);
|
||||
|
||||
session()->put("oidc_id_token", $idTokenText);
|
||||
|
||||
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
|
||||
'access_token' => $accessToken->getToken(),
|
||||
'expires_in' => $accessToken->getExpires(),
|
||||
@ -284,4 +295,30 @@ class OidcService
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -71,8 +71,7 @@ class Saml2Service
|
||||
throw $error;
|
||||
}
|
||||
|
||||
$this->actionLogout();
|
||||
$url = '/';
|
||||
$url = $this->loginService->logout();
|
||||
$id = null;
|
||||
}
|
||||
|
||||
@ -140,20 +139,11 @@ class Saml2Service
|
||||
);
|
||||
}
|
||||
|
||||
$this->actionLogout();
|
||||
$this->loginService->logout();
|
||||
|
||||
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.
|
||||
*
|
||||
|
@ -2,69 +2,24 @@
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Auth\Access\handler;
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\Factory as Socialite;
|
||||
use Laravel\Socialite\Contracts\Provider;
|
||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
use Laravel\Socialite\Two\GoogleProvider;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class SocialAuthService
|
||||
{
|
||||
/**
|
||||
* The core socialite library used.
|
||||
*
|
||||
* @var Socialite
|
||||
*/
|
||||
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;
|
||||
public function __construct(
|
||||
protected Socialite $socialite,
|
||||
protected LoginService $loginService,
|
||||
protected SocialDriverManager $driverManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -74,9 +29,10 @@ class SocialAuthService
|
||||
*/
|
||||
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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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)
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$socialId = $socialUser->getId();
|
||||
|
||||
// Get any attached social accounts or users
|
||||
@ -181,76 +140,11 @@ class SocialAuthService
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the social driver is correct and supported.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
* Get the social driver manager used by this service.
|
||||
*/
|
||||
protected function validateDriver(string $socialDriver): string
|
||||
public function drivers(): SocialDriverManager
|
||||
{
|
||||
$driver = trim(strtolower($socialDriver));
|
||||
|
||||
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;
|
||||
return $this->driverManager;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -284,33 +178,8 @@ class SocialAuthService
|
||||
$driver->with(['prompt' => 'select_account']);
|
||||
}
|
||||
|
||||
if (isset($this->configureForRedirectCallbacks[$driverName])) {
|
||||
$this->configureForRedirectCallbacks[$driverName]($driver);
|
||||
}
|
||||
$this->driverManager->getConfigureForRedirectCallback($driverName)($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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
147
app/Access/SocialDriverManager.php
Normal file
147
app/Access/SocialDriverManager.php
Normal 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);
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Activity\Tools\ActivityLogger;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
@ -36,7 +36,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
public $singletons = [
|
||||
'activity' => ActivityLogger::class,
|
||||
SettingService::class => SettingService::class,
|
||||
SocialAuthService::class => SocialAuthService::class,
|
||||
SocialDriverManager::class => SocialDriverManager::class,
|
||||
CspService::class => CspService::class,
|
||||
HttpRequestService::class => HttpRequestService::class,
|
||||
];
|
||||
|
@ -36,6 +36,12 @@ return [
|
||||
'authorization_endpoint' => env('OIDC_AUTH_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
|
||||
// Multiple values can be provided comma seperated.
|
||||
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
|
||||
@ -45,6 +51,6 @@ return [
|
||||
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
|
||||
// Attribute, within a OIDC ID token, to find group names within
|
||||
'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),
|
||||
];
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\ThemeException;
|
||||
use Illuminate\Console\Application;
|
||||
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
|
||||
{
|
||||
$socialAuthService = app()->make(SocialAuthService::class);
|
||||
$socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
|
||||
$driverManager = app()->make(SocialDriverManager::class);
|
||||
$driverManager->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Users\Controllers;
|
||||
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
@ -161,7 +161,7 @@ class UserAccountController extends Controller
|
||||
/**
|
||||
* 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');
|
||||
|
||||
@ -171,7 +171,7 @@ class UserAccountController extends Controller
|
||||
'category' => 'auth',
|
||||
'mfaMethods' => $mfaMethods,
|
||||
'authMethod' => config('auth.method'),
|
||||
'activeSocialDrivers' => $socialAuthService->getActiveDrivers(),
|
||||
'activeSocialDrivers' => $socialDriverManager->getActive(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Users\Controllers;
|
||||
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Http\Controller;
|
||||
@ -101,7 +101,7 @@ class UserController extends Controller
|
||||
/**
|
||||
* 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');
|
||||
|
||||
@ -109,7 +109,7 @@ class UserController extends Controller
|
||||
$user->load(['apiTokens', 'mfaValues']);
|
||||
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
|
||||
|
||||
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
|
||||
$activeSocialDrivers = $socialDriverManager->getActive();
|
||||
$mfaMethods = $user->mfaValues->groupBy('method');
|
||||
$this->setPageTitle(trans('settings.user_profile'));
|
||||
$roles = Role::query()->orderBy('display_name', 'asc')->get();
|
||||
|
@ -29,8 +29,14 @@
|
||||
</li>
|
||||
<li><hr></li>
|
||||
<li>
|
||||
<form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}"
|
||||
method="post">
|
||||
@php
|
||||
$logoutPath = match (config('auth.method')) {
|
||||
'saml2' => '/saml2/logout',
|
||||
'oidc' => '/oidc/logout',
|
||||
default => '/logout',
|
||||
}
|
||||
@endphp
|
||||
<form action="{{ url($logoutPath) }}" method="post">
|
||||
{{ csrf_field() }}
|
||||
<button class="icon-item" data-shortcut="logout">
|
||||
@icon('logout')
|
||||
|
@ -332,6 +332,7 @@ Route::get('/saml2/acs', [AccessControllers\Saml2Controller::class, 'processAcs'
|
||||
// OIDC routes
|
||||
Route::post('/oidc/login', [AccessControllers\OidcController::class, 'login']);
|
||||
Route::get('/oidc/callback', [AccessControllers\OidcController::class, 'callback']);
|
||||
Route::post('/oidc/logout', [AccessControllers\OidcController::class, 'logout']);
|
||||
|
||||
// User invitation routes
|
||||
Route::get('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'showSetPassword']);
|
||||
|
@ -44,6 +44,7 @@ class OidcTest extends TestCase
|
||||
'oidc.groups_claim' => 'group',
|
||||
'oidc.remove_from_groups' => false,
|
||||
'oidc.external_id_claim' => 'sub',
|
||||
'oidc.end_session_endpoint' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -478,6 +479,128 @@ class OidcTest extends TestCase
|
||||
$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()
|
||||
{
|
||||
$args = [];
|
||||
@ -563,6 +686,7 @@ class OidcTest extends TestCase
|
||||
'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
|
||||
'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
|
||||
'issuer' => OidcJwtHelper::defaultIssuer(),
|
||||
'end_session_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/logout',
|
||||
], $responseOverrides)));
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
|
||||
class DebugViewTest extends TestCase
|
||||
@ -46,8 +46,8 @@ class DebugViewTest extends TestCase
|
||||
protected function getDebugViewForException(\Exception $exception): TestResponse
|
||||
{
|
||||
// Fake an error via social auth service used on login page
|
||||
$mockService = $this->mock(SocialAuthService::class);
|
||||
$mockService->shouldReceive('getActiveDrivers')->andThrow($exception);
|
||||
$mockService = $this->mock(SocialDriverManager::class);
|
||||
$mockService->shouldReceive('getActive')->andThrow($exception);
|
||||
|
||||
return $this->get('/login');
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user