From 978cfbbc8b34086fb384630d7704349a6bd5655d Mon Sep 17 00:00:00 2001 From: Holger Heidkamp Date: Tue, 24 Mar 2026 13:01:54 +0100 Subject: [PATCH 1/8] Add configurable multi-auth support --- .env.example.complete | 8 +++ app/Access/Controllers/LoginController.php | 67 +++++++++++++---- app/Access/Controllers/RegisterController.php | 2 +- .../Controllers/ResetPasswordController.php | 2 +- app/Access/LdapService.php | 2 +- app/Access/LoginService.php | 60 +++++++++++++--- app/Access/RegistrationService.php | 7 +- app/Access/SocialAuthService.php | 2 +- app/App/helpers.php | 71 +++++++++++++++++++ app/Config/auth.php | 10 ++- app/Http/Middleware/CheckGuard.php | 4 +- .../Controllers/UserAccountController.php | 5 +- app/Users/Controllers/UserController.php | 15 ++-- resources/views/auth/login.blade.php | 10 ++- .../auth/parts/login-form-ldap.blade.php | 5 +- .../auth/parts/login-form-oidc.blade.php | 2 +- .../auth/parts/login-form-saml2.blade.php | 4 +- .../auth/parts/login-form-standard.blade.php | 5 +- .../layouts/parts/header-links.blade.php | 4 +- .../layouts/parts/header-user-menu.blade.php | 4 +- .../categories/registration.blade.php | 4 +- .../views/settings/roles/parts/form.blade.php | 4 +- resources/views/users/account/auth.blade.php | 2 +- resources/views/users/parts/form.blade.php | 8 +-- 24 files changed, 242 insertions(+), 65 deletions(-) diff --git a/.env.example.complete b/.env.example.complete index ebebaf9e3e8..96da206faf7 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -152,6 +152,14 @@ STORAGE_URL=false # Can be 'standard', 'ldap', 'saml2' or 'oidc' AUTH_METHOD=standard +# Comma-separated list of enabled authentication methods. +# If left empty, AUTH_METHOD will be used as a single-method fallback. +AUTH_METHODS= + +# Primary method to prefer for UI and redirect behavior. +# If left empty, AUTH_METHOD is used, then the first item in AUTH_METHODS. +AUTH_PRIMARY_METHOD= + # Automatically initiate login via external auth system if it's the only auth method. # Works with saml2 or oidc auth methods. AUTH_AUTO_INITIATE=false diff --git a/app/Access/Controllers/LoginController.php b/app/Access/Controllers/LoginController.php index ce872ba88dc..05bceed4e93 100644 --- a/app/Access/Controllers/LoginController.php +++ b/app/Access/Controllers/LoginController.php @@ -31,7 +31,8 @@ public function __construct( public function getLogin(Request $request) { $socialDrivers = $this->socialDriverManager->getActive(); - $authMethod = config('auth.method'); + $authMethods = $this->getEnabledLoginMethods(); + $primaryAuthMethod = auth_primary_method(); $preventInitiation = $request->get('prevent_auto_init') === 'true'; if ($request->has('email')) { @@ -46,13 +47,14 @@ public function getLogin(Request $request) if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) { return view('auth.login-initiate', [ - 'authMethod' => $authMethod, + 'authMethod' => $primaryAuthMethod, ]); } return view('auth.login', [ - 'socialDrivers' => $socialDrivers, - 'authMethod' => $authMethod, + 'socialDrivers' => $socialDrivers, + 'authMethods' => $authMethods, + 'primaryAuthMethod' => $primaryAuthMethod, ]); } @@ -61,8 +63,10 @@ public function getLogin(Request $request) */ public function login(Request $request) { - $this->validateLogin($request); - $username = $request->get($this->username()); + $loginMethod = $this->getRequestedLoginMethod($request); + $this->ensureMethodEnabled($loginMethod); + $this->validateLogin($request, $loginMethod); + $username = $request->get($this->username($loginMethod)); // Check login throttling attempts to see if they've gone over the limit if ($this->hasTooManyLoginAttempts($request)) { @@ -86,7 +90,7 @@ public function login(Request $request) // Throw validation failure for failed login throw ValidationException::withMessages([ - $this->username() => [trans('auth.failed')], + $this->username($loginMethod) => [trans('auth.failed')], ])->redirectTo('/login'); } @@ -101,9 +105,10 @@ public function logout() /** * Get the expected username input based upon the current auth method. */ - protected function username(): string + protected function username(?string $method = null): string { - return config('auth.method') === 'standard' ? 'email' : 'username'; + $method ??= $this->getRequestedLoginMethod(request()); + return $method === 'standard' ? 'email' : 'username'; } /** @@ -131,9 +136,11 @@ protected function sendLoginResponse(Request $request) */ protected function attemptLogin(Request $request): bool { + $loginMethod = $this->getRequestedLoginMethod($request); + return $this->loginService->attempt( $this->credentials($request), - auth()->getDefaultDriver(), + $loginMethod, $request->filled('remember') ); } @@ -143,10 +150,9 @@ protected function attemptLogin(Request $request): bool * Validate the user login request. * @throws ValidationException */ - protected function validateLogin(Request $request): void + protected function validateLogin(Request $request, string $authMethod): void { $rules = ['password' => ['required', 'string']]; - $authMethod = config('auth.method'); if ($authMethod === 'standard') { $rules['email'] = ['required', 'email']; @@ -160,6 +166,43 @@ protected function validateLogin(Request $request): void $request->validate($rules); } + /** + * Get the login methods to display on the login page. + * + * @return array + */ + protected function getEnabledLoginMethods(): array + { + return array_values(array_filter(auth_methods(), fn (string $method) => in_array($method, ['standard', 'ldap', 'saml2', 'oidc']))); + } + + /** + * Get the requested method for a credential-based login post. + */ + protected function getRequestedLoginMethod(Request $request): string + { + $method = $request->string('login_method')->toString(); + if ($method === '' && auth_method_enabled('standard')) { + return 'standard'; + } + + if ($method === '' && auth_method_enabled('ldap')) { + return 'ldap'; + } + + return in_array($method, ['standard', 'ldap']) ? $method : auth_primary_method(); + } + + /** + * Ensure the given method is enabled for login. + */ + protected function ensureMethodEnabled(string $method): void + { + if (!auth_method_enabled($method)) { + $this->showPermissionError('/login'); + } + } + /** * Send a response when a login attempt exception occurs. */ diff --git a/app/Access/Controllers/RegisterController.php b/app/Access/Controllers/RegisterController.php index f0261fba80d..8b627cd13f4 100644 --- a/app/Access/Controllers/RegisterController.php +++ b/app/Access/Controllers/RegisterController.php @@ -52,7 +52,7 @@ public function postRegister(Request $request) try { $user = $this->registrationService->registerUser($userData); - $this->loginService->login($user, auth()->getDefaultDriver()); + $this->loginService->login($user, 'standard'); } catch (UserRegistrationException $exception) { if ($exception->getMessage()) { $this->showErrorNotification($exception->getMessage()); diff --git a/app/Access/Controllers/ResetPasswordController.php b/app/Access/Controllers/ResetPasswordController.php index 3af65d17fb6..8f5f81795ce 100644 --- a/app/Access/Controllers/ResetPasswordController.php +++ b/app/Access/Controllers/ResetPasswordController.php @@ -55,7 +55,7 @@ public function reset(Request $request) $user->setRememberToken(Str::random(60)); $user->save(); - $this->loginService->login($user, auth()->getDefaultDriver()); + $this->loginService->login($user, 'standard'); }); // If the password was successfully reset, we will redirect the user back to diff --git a/app/Access/LdapService.php b/app/Access/LdapService.php index 0f456efc247..e4125d884bc 100644 --- a/app/Access/LdapService.php +++ b/app/Access/LdapService.php @@ -29,7 +29,7 @@ public function __construct( protected GroupSyncService $groupSyncService ) { $this->config = config('services.ldap'); - $this->enabled = config('auth.method') === 'ldap'; + $this->enabled = auth_method_enabled('ldap'); } /** diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php index c81e955722c..9d6d0217f63 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -17,6 +17,7 @@ class LoginService { protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted'; + protected const SESSION_METHOD_KEY = 'auth-login-method'; public function __construct( protected MfaSession $mfaSession, @@ -35,18 +36,24 @@ public function __construct( */ public function login(User $user, string $method, bool $remember = false): void { + $sessionMethod = in_array($method, ['standard', 'ldap', 'saml2', 'oidc']) ? $method : 'standard'; + if ($user->isGuest()) { throw new LoginAttemptInvalidUserException('Login not allowed for guest user'); } if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) { - $this->setLastLoginAttemptedForUser($user, $method, $remember); + $this->setLastLoginAttemptedForUser($user, $sessionMethod, $remember); throw new StoppedAuthenticationException($user, $this); } $this->clearLastLoginAttempted(); - auth()->login($user, $remember); + $this->setSessionLoginMethod($sessionMethod); + auth('standard')->login($user, $remember); + if (in_array($method, ['ldap', 'saml2', 'oidc'])) { + auth($method)->login($user, $remember); + } Activity::add(ActivityType::AUTH_LOGIN, "{$method}; {$user->logDescriptor()}"); Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user); @@ -162,10 +169,10 @@ public function attempt(array $credentials, string $method, bool $remember = fal return false; } - $result = auth()->attempt($credentials, $remember); + $result = auth($method)->attempt($credentials, $remember); if ($result) { - $user = auth()->user(); - auth()->logout(); + $user = auth($method)->user(); + auth($method)->logout(); try { $this->login($user, $method, $remember); } catch (LoginAttemptInvalidUserException $e) { @@ -198,17 +205,18 @@ protected function areCredentialsForGuest(array $credentials): bool */ public function logout(): string { - auth()->logout(); + $logoutMethod = $this->getSessionLoginMethod(); + $this->logoutFromAllGuards(); session()->invalidate(); session()->regenerateToken(); - return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/'; + return $this->shouldAutoInitiate($logoutMethod) ? '/login?prevent_auto_init=true' : '/'; } /** * Check if login auto-initiate should be active based upon authentication config. */ - public function shouldAutoInitiate(): bool + public function shouldAutoInitiate(?string $method = null): bool { $autoRedirect = config('auth.auto_initiate'); if (!$autoRedirect) { @@ -216,8 +224,40 @@ public function shouldAutoInitiate(): bool } $socialDrivers = $this->socialDriverManager->getActive(); - $authMethod = config('auth.method'); + $authMethod = $method ?? auth_primary_method(); + $enabledMethods = auth_methods(); + + return count($socialDrivers) === 0 + && count($enabledMethods) === 1 + && in_array($authMethod, ['oidc', 'saml2']); + } + + /** + * Get the login method stored for the current session. + */ + public function getSessionLoginMethod(): string + { + return auth_session_method(); + } + + /** + * Persist the method used for the current session login. + */ + protected function setSessionLoginMethod(string $method): void + { + session()->put(self::SESSION_METHOD_KEY, $method); + } + + /** + * Log the user out of all supported guards to fully clear auth state. + */ + protected function logoutFromAllGuards(): void + { + foreach (['standard', 'ldap', 'saml2', 'oidc'] as $guard) { + auth($guard)->logout(); + } - return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']); + session()->remove(self::SESSION_METHOD_KEY); + $this->clearLastLoginAttempted(); } } diff --git a/app/Access/RegistrationService.php b/app/Access/RegistrationService.php index e47479e7991..c4dc959a3e1 100644 --- a/app/Access/RegistrationService.php +++ b/app/Access/RegistrationService.php @@ -38,10 +38,7 @@ public function ensureRegistrationAllowed() */ protected function registrationAllowed(): bool { - $authMethod = config('auth.method'); - $authMethodsWithRegistration = ['standard']; - - return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled'); + return auth_method_enabled('standard') && setting('registration-enabled'); } /** @@ -78,7 +75,7 @@ public function findOrRegister(string $name, string $email, string $externalId): public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User { $userEmail = $userData['email']; - $authSystem = $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(); + $authSystem = $socialAccount ? $socialAccount->driver : 'standard'; // Email restriction $this->ensureEmailDomainAllowed($userEmail); diff --git a/app/Access/SocialAuthService.php b/app/Access/SocialAuthService.php index c3c20587db3..b8788adee65 100644 --- a/app/Access/SocialAuthService.php +++ b/app/Access/SocialAuthService.php @@ -132,7 +132,7 @@ public function handleLoginCallback(string $socialDriver, SocialUser $socialUser // Otherwise let the user know this social account is not used by anyone. $message = trans('errors.social_account_not_used', ['socialAccount' => $titleCaseDriver]); - if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') { + if (setting('registration-enabled') && auth_method_enabled('standard')) { $message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]); } diff --git a/app/App/helpers.php b/app/App/helpers.php index 8f210ecafd4..80dfbcb1844 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -36,6 +36,77 @@ function user(): User return auth()->user() ?: User::getGuest(); } +/** + * Get the enabled authentication methods in configured priority order. + * + * @return array + */ +function auth_methods(): array +{ + $validMethods = ['standard', 'ldap', 'saml2', 'oidc']; + $methodsConfig = config('auth.methods', ''); + $singleMethod = config('auth.method', 'standard'); + + $methods = is_string($methodsConfig) + ? array_map('trim', explode(',', $methodsConfig)) + : (array) $methodsConfig; + + $methods = array_values(array_unique(array_filter($methods, function (mixed $method) use ($validMethods) { + return is_string($method) && in_array($method, $validMethods); + }))); + + if (count($methods) === 0 && in_array($singleMethod, $validMethods)) { + $methods[] = $singleMethod; + } + + return $methods; +} + +/** + * Check if the given authentication method is enabled. + */ +function auth_method_enabled(string $method): bool +{ + return in_array($method, auth_methods()); +} + +/** + * Get the primary configured authentication method. + */ +function auth_primary_method(): string +{ + $primaryMethod = config('auth.primary_method', ''); + if (is_string($primaryMethod) && auth_method_enabled($primaryMethod)) { + return $primaryMethod; + } + + $singleMethod = config('auth.method', 'standard'); + if (is_string($singleMethod) && auth_method_enabled($singleMethod)) { + return $singleMethod; + } + + return auth_methods()[0] ?? 'standard'; +} + +/** + * Get the authentication method used for the current session, where known. + */ +function auth_session_method(): string +{ + $sessionMethod = session()->get('auth-login-method'); + if (is_string($sessionMethod) && auth_method_enabled($sessionMethod)) { + return $sessionMethod; + } + + foreach (['standard', 'ldap', 'oidc', 'saml2'] as $guard) { + if (auth_method_enabled($guard) && auth($guard)->check()) { + return $guard; + } + } + + return auth_primary_method(); +} + /** * Check if the current user has a permission. If an ownable element * is passed in the jointPermissions are checked against that particular item. diff --git a/app/Config/auth.php b/app/Config/auth.php index b1578fdb708..46eb8d2754e 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -13,6 +13,14 @@ // Options: standard, ldap, saml2, oidc 'method' => env('AUTH_METHOD', 'standard'), + // Comma-separated list of active authentication methods. + // If empty, AUTH_METHOD will be used as a single-method fallback. + 'methods' => env('AUTH_METHODS', ''), + + // Primary authentication method to prefer for UI/redirect behavior. + // If empty, AUTH_METHOD is used, then the first enabled method. + 'primary_method' => env('AUTH_PRIMARY_METHOD', ''), + // Automatically initiate login via external auth system if it's the sole auth method. // Works with saml2 or oidc auth methods. 'auto_initiate' => env('AUTH_AUTO_INITIATE', false), @@ -21,7 +29,7 @@ // This option controls the default authentication "guard" and password // reset options for your application. 'defaults' => [ - 'guard' => env('AUTH_METHOD', 'standard'), + 'guard' => 'standard', 'passwords' => 'users', ], diff --git a/app/Http/Middleware/CheckGuard.php b/app/Http/Middleware/CheckGuard.php index adc1d1f3ec0..7915bbb2ab5 100644 --- a/app/Http/Middleware/CheckGuard.php +++ b/app/Http/Middleware/CheckGuard.php @@ -17,8 +17,8 @@ class CheckGuard */ public function handle($request, Closure $next, ...$allowedGuards) { - $activeGuard = config('auth.method'); - if (!in_array($activeGuard, $allowedGuards)) { + $enabledAllowedGuards = array_filter($allowedGuards, fn (string $guard) => auth_method_enabled($guard)); + if (count($enabledAllowedGuards) === 0) { session()->flash('error', trans('errors.permission')); return redirect('/'); diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index a8baba5294b..70fa3d32d4b 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -171,7 +171,8 @@ public function showAuth(SocialDriverManager $socialDriverManager) return view('users.account.auth', [ 'category' => 'auth', 'mfaMethods' => $mfaMethods, - 'authMethod' => config('auth.method'), + 'authMethods' => auth_methods(), + 'sessionAuthMethod' => auth_session_method(), 'activeSocialDrivers' => $socialDriverManager->getActive(), ]); } @@ -183,7 +184,7 @@ public function updatePassword(Request $request) { $this->preventAccessInDemoMode(); - if (config('auth.method') !== 'standard') { + if (!auth_method_enabled('standard') || auth_session_method() !== 'standard') { $this->showPermissionError(); } diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index 494221b143e..9810db26b78 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -60,11 +60,14 @@ public function index(Request $request) public function create() { $this->checkPermission(Permission::UsersManage); - $authMethod = config('auth.method'); $roles = Role::query()->orderBy('display_name', 'asc')->get(); $this->setPageTitle(trans('settings.users_add_new')); - return view('users.create', ['authMethod' => $authMethod, 'roles' => $roles]); + return view('users.create', [ + 'authMethod' => auth_primary_method(), + 'authMethods' => auth_methods(), + 'roles' => $roles, + ]); } /** @@ -76,10 +79,9 @@ public function store(Request $request) { $this->checkPermission(Permission::UsersManage); - $authMethod = config('auth.method'); $sendInvite = ($request->get('send_invite', 'false') === 'true'); - $externalAuth = $authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'oidc'; - $passwordRequired = ($authMethod === 'standard' && !$sendInvite); + $externalAuth = array_intersect(auth_methods(), ['ldap', 'saml2', 'oidc']) !== []; + $passwordRequired = (auth_method_enabled('standard') && !$sendInvite); $validationRules = [ 'name' => ['required', 'max:100'], @@ -116,7 +118,7 @@ public function edit(int $id, SocialDriverManager $socialDriverManager) $user = $this->userRepo->getById($id); $user->load(['apiTokens', 'mfaValues']); - $authMethod = ($user->system_name) ? 'system' : config('auth.method'); + $authMethod = $user->system_name ? 'system' : auth_primary_method(); $activeSocialDrivers = $socialDriverManager->getActive(); $mfaMethods = $user->mfaValues->groupBy('method'); @@ -128,6 +130,7 @@ public function edit(int $id, SocialDriverManager $socialDriverManager) 'activeSocialDrivers' => $activeSocialDrivers, 'mfaMethods' => $mfaMethods, 'authMethod' => $authMethod, + 'authMethods' => auth_methods(), 'roles' => $roles, ]); } diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 6278adcd7a8..0c760a806e3 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -11,7 +11,13 @@ @include('auth.parts.login-message') - @include('auth.parts.login-form-' . $authMethod) + @foreach($authMethods as $authMethod) + @if(!$loop->first) +
+ @endif + + @include('auth.parts.login-form-' . $authMethod, ['formId' => 'login-form-' . $authMethod]) + @endforeach @if(count($socialDrivers) > 0)
@@ -25,7 +31,7 @@ @endforeach @endif - @if(setting('registration-enabled') && config('auth.method') === 'standard') + @if(setting('registration-enabled') && auth_method_enabled('standard'))

{{ trans('auth.dont_have_account') }} diff --git a/resources/views/auth/parts/login-form-ldap.blade.php b/resources/views/auth/parts/login-form-ldap.blade.php index 92eba80e8c8..5e403e73a40 100644 --- a/resources/views/auth/parts/login-form-ldap.blade.php +++ b/resources/views/auth/parts/login-form-ldap.blade.php @@ -1,5 +1,6 @@ -
+ {!! csrf_field() !!} +
@@ -25,4 +26,4 @@
-
\ No newline at end of file + diff --git a/resources/views/auth/parts/login-form-oidc.blade.php b/resources/views/auth/parts/login-form-oidc.blade.php index e5e1b70fc59..1ea3fda5bfb 100644 --- a/resources/views/auth/parts/login-form-oidc.blade.php +++ b/resources/views/auth/parts/login-form-oidc.blade.php @@ -1,4 +1,4 @@ -
+ {!! csrf_field() !!}
diff --git a/resources/views/auth/parts/login-form-saml2.blade.php b/resources/views/auth/parts/login-form-saml2.blade.php index 1afd2d9bb6d..c45077cdda9 100644 --- a/resources/views/auth/parts/login-form-saml2.blade.php +++ b/resources/views/auth/parts/login-form-saml2.blade.php @@ -1,4 +1,4 @@ - + {!! csrf_field() !!}
@@ -8,4 +8,4 @@
- \ No newline at end of file + diff --git a/resources/views/auth/parts/login-form-standard.blade.php b/resources/views/auth/parts/login-form-standard.blade.php index 71989dc2f23..21cfa484942 100644 --- a/resources/views/auth/parts/login-form-standard.blade.php +++ b/resources/views/auth/parts/login-form-standard.blade.php @@ -1,5 +1,6 @@ -
+ {!! csrf_field() !!} +
@@ -32,5 +33,3 @@
- - diff --git a/resources/views/layouts/parts/header-links.blade.php b/resources/views/layouts/parts/header-links.blade.php index c3d2f58da17..f968a47d665 100644 --- a/resources/views/layouts/parts/header-links.blade.php +++ b/resources/views/layouts/parts/header-links.blade.php @@ -18,8 +18,8 @@ @endif @if(user()->isGuest()) - @if(setting('registration-enabled') && config('auth.method') === 'standard') + @if(setting('registration-enabled') && auth_method_enabled('standard')) @icon('new-user'){{ trans('auth.sign_up') }} @endif @icon('login'){{ trans('auth.log_in') }} -@endif \ No newline at end of file +@endif diff --git a/resources/views/layouts/parts/header-user-menu.blade.php b/resources/views/layouts/parts/header-user-menu.blade.php index c252deb8218..4729b7f9f95 100644 --- a/resources/views/layouts/parts/header-user-menu.blade.php +++ b/resources/views/layouts/parts/header-user-menu.blade.php @@ -40,7 +40,7 @@ class="icon-item">

  • @php - $logoutPath = match (config('auth.method')) { + $logoutPath = match (auth_session_method()) { 'saml2' => '/saml2/logout', 'oidc' => '/oidc/logout', default => '/logout', @@ -55,4 +55,4 @@ class="icon-item">
  • -
    \ No newline at end of file +
    diff --git a/resources/views/settings/categories/registration.blade.php b/resources/views/settings/categories/registration.blade.php index 1666cef53cd..49ee00ef9dc 100644 --- a/resources/views/settings/categories/registration.blade.php +++ b/resources/views/settings/categories/registration.blade.php @@ -19,7 +19,7 @@ 'label' => trans('settings.reg_enable_toggle') ]) - @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc'])) + @if(count(array_intersect(auth_methods(), ['ldap', 'saml2', 'oidc'])) > 0)
    {{ trans('settings.reg_enable_external_warning') }}
    @endif @@ -74,4 +74,4 @@ class="setting-list-label">{{ trans('settings.reg_confirm_restrict_domain') }}{{ trans('settings.settings_save') }}
    -@endsection \ No newline at end of file +@endsection diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 5a9eca7d2cd..fcacb34d2c5 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -17,7 +17,7 @@ @include('form.checkbox', ['name' => 'mfa_enforced', 'label' => trans('settings.role_mfa_enforced'), 'model' => $role ]) - @if(in_array(config('auth.method'), ['ldap', 'saml2', 'oidc'])) + @if(count(array_intersect(auth_methods(), ['ldap', 'saml2', 'oidc'])) > 0)
    @include('form.text', ['name' => 'external_auth_id', 'model' => $role]) @@ -92,4 +92,4 @@ class="item-list toggle-switch-list">

    - \ No newline at end of file + diff --git a/resources/views/users/account/auth.blade.php b/resources/views/users/account/auth.blade.php index 57e6c1f9cb3..4f32dbbb584 100644 --- a/resources/views/users/account/auth.blade.php +++ b/resources/views/users/account/auth.blade.php @@ -2,7 +2,7 @@ @section('main') - @if($authMethod === 'standard') + @if(auth_method_enabled('standard') && $sessionAuthMethod === 'standard')
    {{ method_field('put') }} diff --git a/resources/views/users/parts/form.blade.php b/resources/views/users/parts/form.blade.php index 86287646f05..49404bad92d 100644 --- a/resources/views/users/parts/form.blade.php +++ b/resources/views/users/parts/form.blade.php @@ -5,10 +5,10 @@
    - @if($authMethod === 'standard') + @if(auth_method_enabled('standard'))

    {{ trans('settings.users_details_desc') }}

    @endif - @if($authMethod === 'ldap' || $authMethod === 'system') + @if($authMethod === 'system' || (auth_method_enabled('standard') === false && auth_method_enabled('ldap')))

    {{ trans('settings.users_details_desc_no_email') }}

    @endif
    @@ -17,7 +17,7 @@ @include('form.text', ['name' => 'name'])
    - @if($authMethod !== 'ldap' || userCan(\BookStack\Permissions\Permission::UsersManage)) + @if($authMethod !== 'ldap' || userCan(\BookStack\Permissions\Permission::UsersManage) || auth_method_enabled('standard')) @include('form.text', ['name' => 'email', 'disabled' => !userCan(\BookStack\Permissions\Permission::UsersManage)]) @endif @@ -44,7 +44,7 @@
    -@if($authMethod === 'standard') +@if(auth_method_enabled('standard') && $authMethod !== 'system')
    From ec77dfbfe1bf3983489696c931c1e3815dded389 Mon Sep 17 00:00:00 2001 From: Holger Heidkamp Date: Tue, 24 Mar 2026 13:03:07 +0100 Subject: [PATCH 2/8] MultiAuth --- .dockerignore | 13 +++ .env.docker-dev.example | 48 +++++++++ .gitignore | 2 + dev/docker/entrypoint.app.sh | 12 +++ dev/docker/fake-oidc/private.pem | 28 ++++++ dev/docker/fake-oidc/public.pem | 9 ++ dev/docker/fake-oidc/router.php | 157 ++++++++++++++++++++++++++++++ dev/docs/docker-dev-multi-auth.md | 88 +++++++++++++++++ docker-compose.dev.yml | 94 ++++++++++++++++++ package-lock.json | 2 +- phpunit.xml | 2 + tests/Auth/MultiAuthTest.php | 71 ++++++++++++++ 12 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.docker-dev.example create mode 100644 dev/docker/fake-oidc/private.pem create mode 100644 dev/docker/fake-oidc/public.pem create mode 100644 dev/docker/fake-oidc/router.php create mode 100644 dev/docs/docker-dev-multi-auth.md create mode 100644 docker-compose.dev.yml create mode 100644 tests/Auth/MultiAuthTest.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..d24cd515ac3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +node_modules +vendor +storage/logs +storage/framework/cache +storage/framework/sessions +storage/framework/testing +storage/framework/views +coverage +.env +.env.docker-dev +docker-compose.dev.override.yml diff --git a/.env.docker-dev.example b/.env.docker-dev.example new file mode 100644 index 00000000000..fa7ae7e7feb --- /dev/null +++ b/.env.docker-dev.example @@ -0,0 +1,48 @@ +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:8080 +APP_KEY=base64:changeme + +DEV_PORT=8080 +DEV_MAIL_PORT=8025 +FAKE_OIDC_PORT=9091 + +DB_DATABASE=bookstack-dev +DB_USERNAME=bookstack-test +DB_PASSWORD=bookstack-test +TEST_DB_DATABASE=bookstack-test + +MAIL_DRIVER=smtp +MAIL_HOST=mailhog +MAIL_PORT=1025 +MAIL_FROM_NAME="BookStack Dev" +MAIL_FROM=dev@example.com + +AUTH_METHOD=standard +AUTH_METHODS=standard,oidc +AUTH_PRIMARY_METHOD=oidc +AUTH_AUTO_INITIATE=false + +# Default fake OIDC provider for local mixed-auth testing. +# This lets you test local accounts + OIDC without a real Entra setup. +OIDC_NAME="Fake OIDC" +OIDC_CLIENT_ID=fake-bookstack-client +OIDC_CLIENT_SECRET=fake-bookstack-secret +OIDC_ISSUER=http://fake-oidc:9000 +OIDC_ISSUER_DISCOVER=false +OIDC_PUBLIC_KEY=file:///app/dev/docker/fake-oidc/public.pem +OIDC_PUBLIC_BASE=http://localhost:9091 +OIDC_AUTH_ENDPOINT=http://localhost:9091/authorize +OIDC_TOKEN_ENDPOINT=http://fake-oidc:9000/token +OIDC_USERINFO_ENDPOINT=http://fake-oidc:9000/userinfo +OIDC_END_SESSION_ENDPOINT=http://localhost:9091/logout +OIDC_ADDITIONAL_SCOPES= + +# If you want to test against Entra later, replace the OIDC_* values above. + +# Optional fake user overrides +FAKE_OIDC_EMAIL=fake.user@example.com +FAKE_OIDC_NAME="Fake OIDC User" +FAKE_OIDC_SUB=fake-oidc-user-001 +FAKE_OIDC_USERNAME=fake.user +FAKE_OIDC_GROUPS=bookstack-users diff --git a/.gitignore b/.gitignore index b545d161f13..dc547d17754 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /coverage Homestead.yaml .env +.env.docker-dev .idea npm-debug.log yarn-error.log @@ -32,4 +33,5 @@ webpack-stats.json phpstan.neon esbuild-meta.json .phpactor.json +/docker-compose.dev.override.yml /*.zip diff --git a/dev/docker/entrypoint.app.sh b/dev/docker/entrypoint.app.sh index b09edda8863..eddaa87677b 100755 --- a/dev/docker/entrypoint.app.sh +++ b/dev/docker/entrypoint.app.sh @@ -9,6 +9,18 @@ if [[ -n "$1" ]]; then else composer install wait-for-it db:3306 -t 45 + mkdir -p storage public/uploads bootstrap/cache + if [[ ! -f .env ]]; then + if [[ -f .env.docker-dev ]]; then + cp .env.docker-dev .env + elif [[ -f .env.example ]]; then + cp .env.example .env + fi + fi + current_app_key=$(grep -E '^APP_KEY=' .env 2>/dev/null | cut -d '=' -f2- || true) + if [[ -z "$current_app_key" || "$current_app_key" == "SomeRandomString" || "$current_app_key" == "base64:changeme" ]]; then + php artisan key:generate --force + fi php artisan migrate --database=mysql --force chown -R www-data storage public/uploads bootstrap/cache exec apache2-foreground diff --git a/dev/docker/fake-oidc/private.pem b/dev/docker/fake-oidc/private.pem new file mode 100644 index 00000000000..cb001147660 --- /dev/null +++ b/dev/docker/fake-oidc/private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb +NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te +g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC +xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp +06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT +dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 +sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ +6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr +4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF +v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW +fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv +HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 +SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf +z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s +HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA +DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh +ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y +uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 +K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi +6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs +IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd +W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 +9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf +efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII +ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl +q/1PY4iJviGKddtmfClH3v4= +-----END PRIVATE KEY----- diff --git a/dev/docker/fake-oidc/public.pem b/dev/docker/fake-oidc/public.pem new file mode 100644 index 00000000000..e74a4f4f821 --- /dev/null +++ b/dev/docker/fake-oidc/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 +DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm +zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i +iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl ++zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk +WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw +3wIDAQAB +-----END PUBLIC KEY----- diff --git a/dev/docker/fake-oidc/router.php b/dev/docker/fake-oidc/router.php new file mode 100644 index 00000000000..88406be61b2 --- /dev/null +++ b/dev/docker/fake-oidc/router.php @@ -0,0 +1,157 @@ +Fake OIDC Provider

    Use /authorize through BookStack.

    '); + break; + + case '/.well-known/openid-configuration': + respondJson([ + 'issuer' => $issuer, + 'authorization_endpoint' => "{$publicBase}/authorize", + 'token_endpoint' => "{$issuer}/token", + 'userinfo_endpoint' => "{$issuer}/userinfo", + 'jwks_uri' => "{$issuer}/keys", + 'end_session_endpoint' => "{$publicBase}/logout", + 'id_token_signing_alg_values_supported' => ['RS256'], + 'subject_types_supported' => ['public'], + 'response_types_supported' => ['code'], + ]); + break; + + case '/authorize': + $redirectUri = $_GET['redirect_uri'] ?? null; + $state = $_GET['state'] ?? ''; + + if (!$redirectUri) { + respondJson(['error' => 'missing_redirect_uri'], 400); + } + + $joiner = str_contains($redirectUri, '?') ? '&' : '?'; + header('Location: ' . $redirectUri . $joiner . http_build_query([ + 'code' => 'fake-auth-code', + 'state' => $state, + ])); + exit; + + case '/token': + if ($method !== 'POST') { + respondJson(['error' => 'method_not_allowed'], 405); + } + + $code = $_POST['code'] ?? ''; + if ($code !== 'fake-auth-code') { + respondJson(['error' => 'invalid_grant'], 400); + } + + respondJson([ + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'access_token' => 'fake-access-token', + 'id_token' => createIdToken([ + 'iss' => $issuer, + 'aud' => $clientId, + 'sub' => $subject, + 'email' => $email, + 'name' => $name, + 'preferred_username' => $username, + 'groups' => $groups, + 'iat' => time(), + 'exp' => time() + 3600, + ]), + ]); + break; + + case '/userinfo': + respondJson([ + 'sub' => $subject, + 'email' => $email, + 'name' => $name, + 'preferred_username' => $username, + 'groups' => $groups, + ]); + break; + + case '/keys': + respondJson([ + 'keys' => [[ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', + 'use' => 'sig', + 'e' => 'AQAB', + 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', + ]], + ]); + break; + + case '/logout': + $redirectUri = $_GET['post_logout_redirect_uri'] ?? 'http://localhost:8080'; + header('Location: ' . $redirectUri); + exit; + + default: + respondJson(['error' => 'not_found', 'path' => $path], 404); +} + +function envValue(string $key, string $default): string +{ + $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); + + return is_string($value) && $value !== '' ? $value : $default; +} + +function createIdToken(array $payload): string +{ + $header = [ + 'typ' => 'JWT', + 'alg' => 'RS256', + 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', + ]; + + $segments = [ + base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES)), + base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES)), + ]; + + $signingInput = implode('.', $segments); + $privateKey = openssl_pkey_get_private(file_get_contents(__DIR__ . '/private.pem')); + openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256); + + $segments[] = base64UrlEncode($signature); + + return implode('.', $segments); +} + +function base64UrlEncode(string $value): string +{ + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); +} + +function respondJson(array $data, int $status = 200): void +{ + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + exit; +} + +function respondHtml(string $html, int $status = 200): void +{ + http_response_code($status); + header('Content-Type: text/html; charset=utf-8'); + echo $html; + exit; +} diff --git a/dev/docs/docker-dev-multi-auth.md b/dev/docs/docker-dev-multi-auth.md new file mode 100644 index 00000000000..1c4c3757f09 --- /dev/null +++ b/dev/docs/docker-dev-multi-auth.md @@ -0,0 +1,88 @@ +# Docker Dev Setup For Multi-Auth Testing + +This repository already ships with a general Docker development setup. This document describes the branch-local stack added for testing mixed authentication modes such as `standard + oidc`. + +By default this stack now includes a fake OIDC provider, so you can test the mixed login flow without real Entra, client secrets or metadata setup. + +## Files + +- `docker-compose.dev.yml`: Dedicated development stack. +- `.env.docker-dev.example`: Example environment for local + OIDC testing. +- `dev/docker/entrypoint.app.sh`: Bootstraps composer, app key and migrations on container startup. + +## Quick Start + +1. Copy the example env file: + + ```bash + cp .env.docker-dev.example .env.docker-dev + ``` + +2. Start the stack: + + ```bash + docker compose --env-file .env.docker-dev -f docker-compose.dev.yml up --build + ``` + +3. Open BookStack at `http://localhost:8080`. + +4. Open MailHog at `http://localhost:8025`. + +5. Use the normal BookStack login form for local accounts, or click the fake OIDC button for the external flow. + +## Default Behavior + +- The app boots with `AUTH_METHODS=standard,oidc`. +- `AUTH_PRIMARY_METHOD=oidc` makes OIDC the preferred external method. +- Local accounts still remain available via the standard login form. +- The fake OIDC provider is exposed on `http://localhost:9091`. +- The default fake OIDC user is `fake.user@example.com`. +- If `APP_KEY` is left as `base64:changeme`, the app container will generate one automatically on first boot. + +## Fake OIDC Provider + +The fake provider auto-approves the authorization request and immediately redirects back to BookStack with a valid code flow result. + +You can adjust the fake identity in `.env.docker-dev` with these optional values: + +- `FAKE_OIDC_EMAIL` +- `FAKE_OIDC_NAME` +- `FAKE_OIDC_SUB` +- `FAKE_OIDC_USERNAME` +- `FAKE_OIDC_GROUPS` + +## Switching To Real Entra Later + +If you want to test against Entra instead of the fake provider, replace the `OIDC_*` values in `.env.docker-dev` and set the auth/token/userinfo/logout endpoints to your real tenant. + +## Useful Commands + +Run migrations or artisan commands: + +```bash +docker compose --env-file .env.docker-dev -f docker-compose.dev.yml run --rm app php artisan list +``` + +Run PHPUnit: + +```bash +docker compose --env-file .env.docker-dev -f docker-compose.dev.yml run --rm app php artisan test +``` + +Run only the multi-auth tests: + +```bash +docker compose --env-file .env.docker-dev -f docker-compose.dev.yml run --rm app php artisan test tests/Auth/MultiAuthTest.php +``` + +Stop and remove containers: + +```bash +docker compose --env-file .env.docker-dev -f docker-compose.dev.yml down +``` + +Reset the database volume: + +```bash +docker compose --env-file .env.docker-dev -f docker-compose.dev.yml down -v +``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000000..72b76737603 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,94 @@ +volumes: + bookstack_dev_db: {} + +services: + fake-oidc: + image: php:8.3-cli-alpine + working_dir: /srv + command: php -S 0.0.0.0:9000 -t /srv /srv/router.php + environment: + OIDC_ISSUER: ${OIDC_ISSUER:-http://fake-oidc:9000} + OIDC_PUBLIC_BASE: ${OIDC_PUBLIC_BASE:-http://localhost:9091} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-fake-bookstack-client} + FAKE_OIDC_EMAIL: ${FAKE_OIDC_EMAIL:-fake.user@example.com} + FAKE_OIDC_NAME: ${FAKE_OIDC_NAME:-Fake OIDC User} + FAKE_OIDC_SUB: ${FAKE_OIDC_SUB:-fake-oidc-user-001} + FAKE_OIDC_USERNAME: ${FAKE_OIDC_USERNAME:-fake.user} + FAKE_OIDC_GROUPS: ${FAKE_OIDC_GROUPS:-bookstack-users} + volumes: + - ./dev/docker/fake-oidc:/srv + ports: + - ${FAKE_OIDC_PORT:-9091}:9000 + + db: + image: mysql:8.4 + environment: + MYSQL_DATABASE: ${DB_DATABASE:-bookstack-dev} + MYSQL_USER: ${DB_USERNAME:-bookstack-test} + MYSQL_PASSWORD: ${DB_PASSWORD:-bookstack-test} + MYSQL_RANDOM_ROOT_PASSWORD: "true" + volumes: + - ./dev/docker/init.db:/docker-entrypoint-initdb.d + - bookstack_dev_db:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 20 + + app: + build: + context: . + dockerfile: ./dev/docker/Dockerfile + depends_on: + db: + condition: service_healthy + environment: + APP_ENV: ${APP_ENV:-local} + APP_DEBUG: ${APP_DEBUG:-true} + APP_URL: ${APP_URL:-http://localhost:8080} + DB_CONNECTION: mysql + DB_HOST: db + DB_PORT: 3306 + DB_DATABASE: ${DB_DATABASE:-bookstack-dev} + DB_USERNAME: ${DB_USERNAME:-bookstack-test} + DB_PASSWORD: ${DB_PASSWORD:-bookstack-test} + TEST_DATABASE_URL: mysql://${DB_USERNAME:-bookstack-test}:${DB_PASSWORD:-bookstack-test}@db/${TEST_DB_DATABASE:-bookstack-test} + MAIL_DRIVER: smtp + MAIL_HOST: mailhog + MAIL_PORT: 1025 + AUTH_METHOD: ${AUTH_METHOD:-standard} + AUTH_METHODS: ${AUTH_METHODS:-standard,oidc} + AUTH_PRIMARY_METHOD: ${AUTH_PRIMARY_METHOD:-oidc} + OIDC_NAME: ${OIDC_NAME:-Fake OIDC} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-fake-bookstack-client} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-fake-bookstack-secret} + OIDC_ISSUER: ${OIDC_ISSUER:-http://fake-oidc:9000} + OIDC_ISSUER_DISCOVER: ${OIDC_ISSUER_DISCOVER:-false} + OIDC_PUBLIC_KEY: ${OIDC_PUBLIC_KEY:-file:///app/dev/docker/fake-oidc/public.pem} + OIDC_AUTH_ENDPOINT: ${OIDC_AUTH_ENDPOINT:-http://localhost:9091/authorize} + OIDC_TOKEN_ENDPOINT: ${OIDC_TOKEN_ENDPOINT:-http://fake-oidc:9000/token} + OIDC_USERINFO_ENDPOINT: ${OIDC_USERINFO_ENDPOINT:-http://fake-oidc:9000/userinfo} + OIDC_END_SESSION_ENDPOINT: ${OIDC_END_SESSION_ENDPOINT:-http://localhost:9091/logout} + OIDC_ADDITIONAL_SCOPES: ${OIDC_ADDITIONAL_SCOPES:-} + ports: + - ${DEV_PORT:-8080}:80 + volumes: + - ./:/app + - ./dev/docker/php/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + entrypoint: /app/dev/docker/entrypoint.app.sh + extra_hosts: + - "host.docker.internal:host-gateway" + + node: + image: node:22-alpine + working_dir: /app + user: node + volumes: + - ./:/app + entrypoint: /app/dev/docker/entrypoint.node.sh + + mailhog: + image: mailhog/mailhog + ports: + - ${DEV_MAIL_PORT:-8025}:8025 diff --git a/package-lock.json b/package-lock.json index b6508f1e9e4..211dd2f5734 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "bookstack", + "name": "app", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/phpunit.xml b/phpunit.xml index 94fc002b704..ccac6c656e3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -31,6 +31,8 @@ + + diff --git a/tests/Auth/MultiAuthTest.php b/tests/Auth/MultiAuthTest.php new file mode 100644 index 00000000000..59649373d0c --- /dev/null +++ b/tests/Auth/MultiAuthTest.php @@ -0,0 +1,71 @@ +set([ + 'auth.methods' => ['standard', 'oidc'], + 'auth.primary_method' => 'oidc', + 'oidc.name' => 'Entra ID', + ]); + $this->setSettings(['registration-enabled' => 'true']); + + $resp = $this->get('/login'); + + $this->withHtml($resp)->assertElementExists('form[action$="/login"] input[name="login_method"][value="standard"]'); + $this->withHtml($resp)->assertElementExists('form[action$="/oidc/login"] button#oidc-login'); + $resp->assertSee('Sign up'); + $resp->assertSee('Entra ID'); + } + + public function test_standard_login_works_when_oidc_is_primary_method() + { + config()->set([ + 'auth.methods' => ['standard', 'oidc'], + 'auth.primary_method' => 'oidc', + ]); + + $resp = $this->post('/login', [ + 'login_method' => 'standard', + 'email' => 'admin@admin.com', + 'password' => 'password', + ]); + + $resp->assertRedirect('/'); + $this->assertTrue(auth()->check()); + $this->assertTrue(auth('standard')->check()); + } + + public function test_auto_initiate_does_not_run_when_multiple_methods_are_enabled() + { + config()->set([ + 'auth.methods' => ['standard', 'oidc'], + 'auth.primary_method' => 'oidc', + 'auth.auto_initiate' => true, + ]); + + $resp = $this->get('/login'); + + $resp->assertDontSeeText('Attempting Login'); + $resp->assertSee('Log In'); + } + + public function test_header_logout_uses_session_auth_method() + { + config()->set([ + 'auth.methods' => ['standard', 'oidc'], + 'auth.primary_method' => 'oidc', + ]); + + $resp = $this->actingAs($this->users->admin()) + ->withSession(['auth-login-method' => 'oidc']) + ->get('/'); + + $this->withHtml($resp)->assertElementExists('form[action="' . url('/oidc/logout') . '"]'); + } +} From 5b0bd6ff7f159b3899d4b12f4f34d016d61b1ea8 Mon Sep 17 00:00:00 2001 From: Holger Heidkamp Date: Tue, 24 Mar 2026 13:11:24 +0100 Subject: [PATCH 3/8] Revert "MultiAuth" This reverts commit ec77dfbfe1bf3983489696c931c1e3815dded389. --- .dockerignore | 13 --- .env.docker-dev.example | 48 --------- .gitignore | 2 - dev/docker/entrypoint.app.sh | 12 --- dev/docker/fake-oidc/private.pem | 28 ------ dev/docker/fake-oidc/public.pem | 9 -- dev/docker/fake-oidc/router.php | 157 ------------------------------ dev/docs/docker-dev-multi-auth.md | 88 ----------------- docker-compose.dev.yml | 94 ------------------ package-lock.json | 2 +- phpunit.xml | 2 - tests/Auth/MultiAuthTest.php | 71 -------------- 12 files changed, 1 insertion(+), 525 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .env.docker-dev.example delete mode 100644 dev/docker/fake-oidc/private.pem delete mode 100644 dev/docker/fake-oidc/public.pem delete mode 100644 dev/docker/fake-oidc/router.php delete mode 100644 dev/docs/docker-dev-multi-auth.md delete mode 100644 docker-compose.dev.yml delete mode 100644 tests/Auth/MultiAuthTest.php diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index d24cd515ac3..00000000000 --- a/.dockerignore +++ /dev/null @@ -1,13 +0,0 @@ -.git -.github -node_modules -vendor -storage/logs -storage/framework/cache -storage/framework/sessions -storage/framework/testing -storage/framework/views -coverage -.env -.env.docker-dev -docker-compose.dev.override.yml diff --git a/.env.docker-dev.example b/.env.docker-dev.example deleted file mode 100644 index fa7ae7e7feb..00000000000 --- a/.env.docker-dev.example +++ /dev/null @@ -1,48 +0,0 @@ -APP_ENV=local -APP_DEBUG=true -APP_URL=http://localhost:8080 -APP_KEY=base64:changeme - -DEV_PORT=8080 -DEV_MAIL_PORT=8025 -FAKE_OIDC_PORT=9091 - -DB_DATABASE=bookstack-dev -DB_USERNAME=bookstack-test -DB_PASSWORD=bookstack-test -TEST_DB_DATABASE=bookstack-test - -MAIL_DRIVER=smtp -MAIL_HOST=mailhog -MAIL_PORT=1025 -MAIL_FROM_NAME="BookStack Dev" -MAIL_FROM=dev@example.com - -AUTH_METHOD=standard -AUTH_METHODS=standard,oidc -AUTH_PRIMARY_METHOD=oidc -AUTH_AUTO_INITIATE=false - -# Default fake OIDC provider for local mixed-auth testing. -# This lets you test local accounts + OIDC without a real Entra setup. -OIDC_NAME="Fake OIDC" -OIDC_CLIENT_ID=fake-bookstack-client -OIDC_CLIENT_SECRET=fake-bookstack-secret -OIDC_ISSUER=http://fake-oidc:9000 -OIDC_ISSUER_DISCOVER=false -OIDC_PUBLIC_KEY=file:///app/dev/docker/fake-oidc/public.pem -OIDC_PUBLIC_BASE=http://localhost:9091 -OIDC_AUTH_ENDPOINT=http://localhost:9091/authorize -OIDC_TOKEN_ENDPOINT=http://fake-oidc:9000/token -OIDC_USERINFO_ENDPOINT=http://fake-oidc:9000/userinfo -OIDC_END_SESSION_ENDPOINT=http://localhost:9091/logout -OIDC_ADDITIONAL_SCOPES= - -# If you want to test against Entra later, replace the OIDC_* values above. - -# Optional fake user overrides -FAKE_OIDC_EMAIL=fake.user@example.com -FAKE_OIDC_NAME="Fake OIDC User" -FAKE_OIDC_SUB=fake-oidc-user-001 -FAKE_OIDC_USERNAME=fake.user -FAKE_OIDC_GROUPS=bookstack-users diff --git a/.gitignore b/.gitignore index dc547d17754..b545d161f13 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ /coverage Homestead.yaml .env -.env.docker-dev .idea npm-debug.log yarn-error.log @@ -33,5 +32,4 @@ webpack-stats.json phpstan.neon esbuild-meta.json .phpactor.json -/docker-compose.dev.override.yml /*.zip diff --git a/dev/docker/entrypoint.app.sh b/dev/docker/entrypoint.app.sh index eddaa87677b..b09edda8863 100755 --- a/dev/docker/entrypoint.app.sh +++ b/dev/docker/entrypoint.app.sh @@ -9,18 +9,6 @@ if [[ -n "$1" ]]; then else composer install wait-for-it db:3306 -t 45 - mkdir -p storage public/uploads bootstrap/cache - if [[ ! -f .env ]]; then - if [[ -f .env.docker-dev ]]; then - cp .env.docker-dev .env - elif [[ -f .env.example ]]; then - cp .env.example .env - fi - fi - current_app_key=$(grep -E '^APP_KEY=' .env 2>/dev/null | cut -d '=' -f2- || true) - if [[ -z "$current_app_key" || "$current_app_key" == "SomeRandomString" || "$current_app_key" == "base64:changeme" ]]; then - php artisan key:generate --force - fi php artisan migrate --database=mysql --force chown -R www-data storage public/uploads bootstrap/cache exec apache2-foreground diff --git a/dev/docker/fake-oidc/private.pem b/dev/docker/fake-oidc/private.pem deleted file mode 100644 index cb001147660..00000000000 --- a/dev/docker/fake-oidc/private.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCqjU6Z80p5zlLb -NALhI/0Ose5RHRWAKLqipwYRHPvOo7fqGqTcDdHdoKAmQSN+ducy6zNFEqzjk1te -g6n2m+bNcvXpz3c5ufsbzHwDdqjrI3moo7lLn4IROo5l8o3+DvleXPtWwLeKA6GC -xec/HqKIzqxthuTRT9ekVpPzgtcojxVMg9SjuBN5k3WlMRP/+L0WXMA7Nf7F9YEp -06qUTOX7OKEwcJlRS9C730/6jQwNHSUxRP688npIl5F+egd7HC3ptkWI2exkgQvT -dtfhA2Ra+wu1rX6M1R577wg9WHMI7xu8zzo3MtopQniTo1nkhWuZ0IWkWyMJYHI6 -sMbzB3DfAgMBAAECggEADm7K2ghWoxwsstQh8j+DaLzx9/dIHIJV2PHdd5FGVeRQ -6gS7MswQmHrBUrtsb4VMZ2iz/AJqkw+jScpGldH3pCc4XELsSfxNHbseO4TNIqjr -4LOKOLYU4bRc3I+8KGXIAI5JzrucTJemEVUCDrte8cjbmqExt+zTyNpyxsapworF -v+vnSdv40d62f+cS1xvwB+ymLK/B/wZ/DemDCi8jsi7ou/M7l5xNCzjH4iMSLtOW -fgEhejIBG9miMJWPiVpTXE3tMdNuN3OsWc4XXm2t4VRovlZdu30Fax1xWB+Locsv -HlHKLOFc8g+jZh0TL2KCNjPffMcC7kHhW3afshpIsQKBgQDhyWUnkqd6FzbwIX70 -SnaMgKoUv5W/K5T+Sv/PA2CyN8Gu8ih/OsoNZSnI0uqe3XQIvvgN/Fq3wO1ttLzf -z5B6ZC7REfTgcR0190gihk6f5rtcj7d6Fy/oG2CE8sDSXgPnpEaBjvJVgN5v/U2s -HpVaidmHTyGLCfEszoeoy8jyrQKBgQDBX8caGylmzQLc6XNntZChlt3e18Nj8MPA -DxWLcoqgdDoofLDQAmLl+vPKyDmhQjos5eas1jgmVVEM4ge+MysaVezvuLBsSnOh -ihc0i63USU6i7YDE83DrCewCthpFHi/wW1S5FoCAzpVy8y99vwcqO4kOXcmf4O6Y -uW6sMsjvOwKBgQDbFtqB+MtsLCSSBF61W6AHHD5tna4H75lG2623yXZF2NanFLF5 -K6muL9DI3ujtOMQETJJUt9+rWJjLEEsJ/dYa/SV0l7D/LKOEnyuu3JZkkLaTzZzi -6qcA2bfhqdCzEKlHV99WjkfV8hNlpex9rLuOPB8JLh7FVONicBGxF/UojQKBgDXs -IlYaSuI6utilVKQP0kPtEPOKERc2VS+iRSy8hQGXR3xwwNFQSQm+f+sFCGT6VcSd -W0TI+6Fc2xwPj38vP465dTentbKM1E+wdSYW6SMwSfhO6ECDbfJsst5Sr2Kkt1N7 -9FUkfDLu6GfEfnK/KR1SurZB2u51R7NYyg7EnplvAoGAT0aTtOcck0oYN30g5mdf -efqXPwg2wAPYeiec49EbfnteQQKAkqNfJ9K69yE2naf6bw3/5mCBsq/cXeuaBMII -ylysUIRBqt2J0kWm2yCpFWR7H+Ilhdx9A7ZLCqYVt8e+vjO/BOI3cQDe2VPOLPSl -q/1PY4iJviGKddtmfClH3v4= ------END PRIVATE KEY----- diff --git a/dev/docker/fake-oidc/public.pem b/dev/docker/fake-oidc/public.pem deleted file mode 100644 index e74a4f4f821..00000000000 --- a/dev/docker/fake-oidc/public.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqo1OmfNKec5S2zQC4SP9 -DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvm -zXL16c93Obn7G8x8A3ao6yN5qKO5S5+CETqOZfKN/g75Xlz7VsC3igOhgsXnPx6i -iM6sbYbk0U/XpFaT84LXKI8VTIPUo7gTeZN1pTET//i9FlzAOzX+xfWBKdOqlEzl -+zihMHCZUUvQu99P+o0MDR0lMUT+vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNk -WvsLta1+jNUee+8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw -3wIDAQAB ------END PUBLIC KEY----- diff --git a/dev/docker/fake-oidc/router.php b/dev/docker/fake-oidc/router.php deleted file mode 100644 index 88406be61b2..00000000000 --- a/dev/docker/fake-oidc/router.php +++ /dev/null @@ -1,157 +0,0 @@ -Fake OIDC Provider

    Use /authorize through BookStack.

    '); - break; - - case '/.well-known/openid-configuration': - respondJson([ - 'issuer' => $issuer, - 'authorization_endpoint' => "{$publicBase}/authorize", - 'token_endpoint' => "{$issuer}/token", - 'userinfo_endpoint' => "{$issuer}/userinfo", - 'jwks_uri' => "{$issuer}/keys", - 'end_session_endpoint' => "{$publicBase}/logout", - 'id_token_signing_alg_values_supported' => ['RS256'], - 'subject_types_supported' => ['public'], - 'response_types_supported' => ['code'], - ]); - break; - - case '/authorize': - $redirectUri = $_GET['redirect_uri'] ?? null; - $state = $_GET['state'] ?? ''; - - if (!$redirectUri) { - respondJson(['error' => 'missing_redirect_uri'], 400); - } - - $joiner = str_contains($redirectUri, '?') ? '&' : '?'; - header('Location: ' . $redirectUri . $joiner . http_build_query([ - 'code' => 'fake-auth-code', - 'state' => $state, - ])); - exit; - - case '/token': - if ($method !== 'POST') { - respondJson(['error' => 'method_not_allowed'], 405); - } - - $code = $_POST['code'] ?? ''; - if ($code !== 'fake-auth-code') { - respondJson(['error' => 'invalid_grant'], 400); - } - - respondJson([ - 'token_type' => 'Bearer', - 'expires_in' => 3600, - 'access_token' => 'fake-access-token', - 'id_token' => createIdToken([ - 'iss' => $issuer, - 'aud' => $clientId, - 'sub' => $subject, - 'email' => $email, - 'name' => $name, - 'preferred_username' => $username, - 'groups' => $groups, - 'iat' => time(), - 'exp' => time() + 3600, - ]), - ]); - break; - - case '/userinfo': - respondJson([ - 'sub' => $subject, - 'email' => $email, - 'name' => $name, - 'preferred_username' => $username, - 'groups' => $groups, - ]); - break; - - case '/keys': - respondJson([ - 'keys' => [[ - 'kty' => 'RSA', - 'alg' => 'RS256', - 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', - 'use' => 'sig', - 'e' => 'AQAB', - 'n' => 'qo1OmfNKec5S2zQC4SP9DrHuUR0VgCi6oqcGERz7zqO36hqk3A3R3aCgJkEjfnbnMuszRRKs45NbXoOp9pvmzXL16c93Obn7G8x8A3ao6yN5qKO5S5-CETqOZfKN_g75Xlz7VsC3igOhgsXnPx6iiM6sbYbk0U_XpFaT84LXKI8VTIPUo7gTeZN1pTET__i9FlzAOzX-xfWBKdOqlEzl-zihMHCZUUvQu99P-o0MDR0lMUT-vPJ6SJeRfnoHexwt6bZFiNnsZIEL03bX4QNkWvsLta1-jNUee-8IPVhzCO8bvM86NzLaKUJ4k6NZ5IVrmdCFpFsjCWByOrDG8wdw3w', - ]], - ]); - break; - - case '/logout': - $redirectUri = $_GET['post_logout_redirect_uri'] ?? 'http://localhost:8080'; - header('Location: ' . $redirectUri); - exit; - - default: - respondJson(['error' => 'not_found', 'path' => $path], 404); -} - -function envValue(string $key, string $default): string -{ - $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); - - return is_string($value) && $value !== '' ? $value : $default; -} - -function createIdToken(array $payload): string -{ - $header = [ - 'typ' => 'JWT', - 'alg' => 'RS256', - 'kid' => '066e52af-8884-4926-801d-032a276f9f2a', - ]; - - $segments = [ - base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES)), - base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES)), - ]; - - $signingInput = implode('.', $segments); - $privateKey = openssl_pkey_get_private(file_get_contents(__DIR__ . '/private.pem')); - openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256); - - $segments[] = base64UrlEncode($signature); - - return implode('.', $segments); -} - -function base64UrlEncode(string $value): string -{ - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); -} - -function respondJson(array $data, int $status = 200): void -{ - http_response_code($status); - header('Content-Type: application/json'); - echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - exit; -} - -function respondHtml(string $html, int $status = 200): void -{ - http_response_code($status); - header('Content-Type: text/html; charset=utf-8'); - echo $html; - exit; -} diff --git a/dev/docs/docker-dev-multi-auth.md b/dev/docs/docker-dev-multi-auth.md deleted file mode 100644 index 1c4c3757f09..00000000000 --- a/dev/docs/docker-dev-multi-auth.md +++ /dev/null @@ -1,88 +0,0 @@ -# Docker Dev Setup For Multi-Auth Testing - -This repository already ships with a general Docker development setup. This document describes the branch-local stack added for testing mixed authentication modes such as `standard + oidc`. - -By default this stack now includes a fake OIDC provider, so you can test the mixed login flow without real Entra, client secrets or metadata setup. - -## Files - -- `docker-compose.dev.yml`: Dedicated development stack. -- `.env.docker-dev.example`: Example environment for local + OIDC testing. -- `dev/docker/entrypoint.app.sh`: Bootstraps composer, app key and migrations on container startup. - -## Quick Start - -1. Copy the example env file: - - ```bash - cp .env.docker-dev.example .env.docker-dev - ``` - -2. Start the stack: - - ```bash - docker compose --env-file .env.docker-dev -f docker-compose.dev.yml up --build - ``` - -3. Open BookStack at `http://localhost:8080`. - -4. Open MailHog at `http://localhost:8025`. - -5. Use the normal BookStack login form for local accounts, or click the fake OIDC button for the external flow. - -## Default Behavior - -- The app boots with `AUTH_METHODS=standard,oidc`. -- `AUTH_PRIMARY_METHOD=oidc` makes OIDC the preferred external method. -- Local accounts still remain available via the standard login form. -- The fake OIDC provider is exposed on `http://localhost:9091`. -- The default fake OIDC user is `fake.user@example.com`. -- If `APP_KEY` is left as `base64:changeme`, the app container will generate one automatically on first boot. - -## Fake OIDC Provider - -The fake provider auto-approves the authorization request and immediately redirects back to BookStack with a valid code flow result. - -You can adjust the fake identity in `.env.docker-dev` with these optional values: - -- `FAKE_OIDC_EMAIL` -- `FAKE_OIDC_NAME` -- `FAKE_OIDC_SUB` -- `FAKE_OIDC_USERNAME` -- `FAKE_OIDC_GROUPS` - -## Switching To Real Entra Later - -If you want to test against Entra instead of the fake provider, replace the `OIDC_*` values in `.env.docker-dev` and set the auth/token/userinfo/logout endpoints to your real tenant. - -## Useful Commands - -Run migrations or artisan commands: - -```bash -docker compose --env-file .env.docker-dev -f docker-compose.dev.yml run --rm app php artisan list -``` - -Run PHPUnit: - -```bash -docker compose --env-file .env.docker-dev -f docker-compose.dev.yml run --rm app php artisan test -``` - -Run only the multi-auth tests: - -```bash -docker compose --env-file .env.docker-dev -f docker-compose.dev.yml run --rm app php artisan test tests/Auth/MultiAuthTest.php -``` - -Stop and remove containers: - -```bash -docker compose --env-file .env.docker-dev -f docker-compose.dev.yml down -``` - -Reset the database volume: - -```bash -docker compose --env-file .env.docker-dev -f docker-compose.dev.yml down -v -``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index 72b76737603..00000000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,94 +0,0 @@ -volumes: - bookstack_dev_db: {} - -services: - fake-oidc: - image: php:8.3-cli-alpine - working_dir: /srv - command: php -S 0.0.0.0:9000 -t /srv /srv/router.php - environment: - OIDC_ISSUER: ${OIDC_ISSUER:-http://fake-oidc:9000} - OIDC_PUBLIC_BASE: ${OIDC_PUBLIC_BASE:-http://localhost:9091} - OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-fake-bookstack-client} - FAKE_OIDC_EMAIL: ${FAKE_OIDC_EMAIL:-fake.user@example.com} - FAKE_OIDC_NAME: ${FAKE_OIDC_NAME:-Fake OIDC User} - FAKE_OIDC_SUB: ${FAKE_OIDC_SUB:-fake-oidc-user-001} - FAKE_OIDC_USERNAME: ${FAKE_OIDC_USERNAME:-fake.user} - FAKE_OIDC_GROUPS: ${FAKE_OIDC_GROUPS:-bookstack-users} - volumes: - - ./dev/docker/fake-oidc:/srv - ports: - - ${FAKE_OIDC_PORT:-9091}:9000 - - db: - image: mysql:8.4 - environment: - MYSQL_DATABASE: ${DB_DATABASE:-bookstack-dev} - MYSQL_USER: ${DB_USERNAME:-bookstack-test} - MYSQL_PASSWORD: ${DB_PASSWORD:-bookstack-test} - MYSQL_RANDOM_ROOT_PASSWORD: "true" - volumes: - - ./dev/docker/init.db:/docker-entrypoint-initdb.d - - bookstack_dev_db:/var/lib/mysql - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - interval: 5s - timeout: 5s - retries: 20 - - app: - build: - context: . - dockerfile: ./dev/docker/Dockerfile - depends_on: - db: - condition: service_healthy - environment: - APP_ENV: ${APP_ENV:-local} - APP_DEBUG: ${APP_DEBUG:-true} - APP_URL: ${APP_URL:-http://localhost:8080} - DB_CONNECTION: mysql - DB_HOST: db - DB_PORT: 3306 - DB_DATABASE: ${DB_DATABASE:-bookstack-dev} - DB_USERNAME: ${DB_USERNAME:-bookstack-test} - DB_PASSWORD: ${DB_PASSWORD:-bookstack-test} - TEST_DATABASE_URL: mysql://${DB_USERNAME:-bookstack-test}:${DB_PASSWORD:-bookstack-test}@db/${TEST_DB_DATABASE:-bookstack-test} - MAIL_DRIVER: smtp - MAIL_HOST: mailhog - MAIL_PORT: 1025 - AUTH_METHOD: ${AUTH_METHOD:-standard} - AUTH_METHODS: ${AUTH_METHODS:-standard,oidc} - AUTH_PRIMARY_METHOD: ${AUTH_PRIMARY_METHOD:-oidc} - OIDC_NAME: ${OIDC_NAME:-Fake OIDC} - OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-fake-bookstack-client} - OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-fake-bookstack-secret} - OIDC_ISSUER: ${OIDC_ISSUER:-http://fake-oidc:9000} - OIDC_ISSUER_DISCOVER: ${OIDC_ISSUER_DISCOVER:-false} - OIDC_PUBLIC_KEY: ${OIDC_PUBLIC_KEY:-file:///app/dev/docker/fake-oidc/public.pem} - OIDC_AUTH_ENDPOINT: ${OIDC_AUTH_ENDPOINT:-http://localhost:9091/authorize} - OIDC_TOKEN_ENDPOINT: ${OIDC_TOKEN_ENDPOINT:-http://fake-oidc:9000/token} - OIDC_USERINFO_ENDPOINT: ${OIDC_USERINFO_ENDPOINT:-http://fake-oidc:9000/userinfo} - OIDC_END_SESSION_ENDPOINT: ${OIDC_END_SESSION_ENDPOINT:-http://localhost:9091/logout} - OIDC_ADDITIONAL_SCOPES: ${OIDC_ADDITIONAL_SCOPES:-} - ports: - - ${DEV_PORT:-8080}:80 - volumes: - - ./:/app - - ./dev/docker/php/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - entrypoint: /app/dev/docker/entrypoint.app.sh - extra_hosts: - - "host.docker.internal:host-gateway" - - node: - image: node:22-alpine - working_dir: /app - user: node - volumes: - - ./:/app - entrypoint: /app/dev/docker/entrypoint.node.sh - - mailhog: - image: mailhog/mailhog - ports: - - ${DEV_MAIL_PORT:-8025}:8025 diff --git a/package-lock.json b/package-lock.json index 211dd2f5734..b6508f1e9e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "app", + "name": "bookstack", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/phpunit.xml b/phpunit.xml index ccac6c656e3..94fc002b704 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -31,8 +31,6 @@ - - diff --git a/tests/Auth/MultiAuthTest.php b/tests/Auth/MultiAuthTest.php deleted file mode 100644 index 59649373d0c..00000000000 --- a/tests/Auth/MultiAuthTest.php +++ /dev/null @@ -1,71 +0,0 @@ -set([ - 'auth.methods' => ['standard', 'oidc'], - 'auth.primary_method' => 'oidc', - 'oidc.name' => 'Entra ID', - ]); - $this->setSettings(['registration-enabled' => 'true']); - - $resp = $this->get('/login'); - - $this->withHtml($resp)->assertElementExists('form[action$="/login"] input[name="login_method"][value="standard"]'); - $this->withHtml($resp)->assertElementExists('form[action$="/oidc/login"] button#oidc-login'); - $resp->assertSee('Sign up'); - $resp->assertSee('Entra ID'); - } - - public function test_standard_login_works_when_oidc_is_primary_method() - { - config()->set([ - 'auth.methods' => ['standard', 'oidc'], - 'auth.primary_method' => 'oidc', - ]); - - $resp = $this->post('/login', [ - 'login_method' => 'standard', - 'email' => 'admin@admin.com', - 'password' => 'password', - ]); - - $resp->assertRedirect('/'); - $this->assertTrue(auth()->check()); - $this->assertTrue(auth('standard')->check()); - } - - public function test_auto_initiate_does_not_run_when_multiple_methods_are_enabled() - { - config()->set([ - 'auth.methods' => ['standard', 'oidc'], - 'auth.primary_method' => 'oidc', - 'auth.auto_initiate' => true, - ]); - - $resp = $this->get('/login'); - - $resp->assertDontSeeText('Attempting Login'); - $resp->assertSee('Log In'); - } - - public function test_header_logout_uses_session_auth_method() - { - config()->set([ - 'auth.methods' => ['standard', 'oidc'], - 'auth.primary_method' => 'oidc', - ]); - - $resp = $this->actingAs($this->users->admin()) - ->withSession(['auth-login-method' => 'oidc']) - ->get('/'); - - $this->withHtml($resp)->assertElementExists('form[action="' . url('/oidc/logout') . '"]'); - } -} From 60c5d020fb61f9394586eb64eba05beea4a25f53 Mon Sep 17 00:00:00 2001 From: Holger Heidkamp Date: Tue, 24 Mar 2026 13:21:31 +0100 Subject: [PATCH 4/8] Create .env.docker-dev --- .env.docker-dev | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .env.docker-dev diff --git a/.env.docker-dev b/.env.docker-dev new file mode 100644 index 00000000000..fa7ae7e7feb --- /dev/null +++ b/.env.docker-dev @@ -0,0 +1,48 @@ +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:8080 +APP_KEY=base64:changeme + +DEV_PORT=8080 +DEV_MAIL_PORT=8025 +FAKE_OIDC_PORT=9091 + +DB_DATABASE=bookstack-dev +DB_USERNAME=bookstack-test +DB_PASSWORD=bookstack-test +TEST_DB_DATABASE=bookstack-test + +MAIL_DRIVER=smtp +MAIL_HOST=mailhog +MAIL_PORT=1025 +MAIL_FROM_NAME="BookStack Dev" +MAIL_FROM=dev@example.com + +AUTH_METHOD=standard +AUTH_METHODS=standard,oidc +AUTH_PRIMARY_METHOD=oidc +AUTH_AUTO_INITIATE=false + +# Default fake OIDC provider for local mixed-auth testing. +# This lets you test local accounts + OIDC without a real Entra setup. +OIDC_NAME="Fake OIDC" +OIDC_CLIENT_ID=fake-bookstack-client +OIDC_CLIENT_SECRET=fake-bookstack-secret +OIDC_ISSUER=http://fake-oidc:9000 +OIDC_ISSUER_DISCOVER=false +OIDC_PUBLIC_KEY=file:///app/dev/docker/fake-oidc/public.pem +OIDC_PUBLIC_BASE=http://localhost:9091 +OIDC_AUTH_ENDPOINT=http://localhost:9091/authorize +OIDC_TOKEN_ENDPOINT=http://fake-oidc:9000/token +OIDC_USERINFO_ENDPOINT=http://fake-oidc:9000/userinfo +OIDC_END_SESSION_ENDPOINT=http://localhost:9091/logout +OIDC_ADDITIONAL_SCOPES= + +# If you want to test against Entra later, replace the OIDC_* values above. + +# Optional fake user overrides +FAKE_OIDC_EMAIL=fake.user@example.com +FAKE_OIDC_NAME="Fake OIDC User" +FAKE_OIDC_SUB=fake-oidc-user-001 +FAKE_OIDC_USERNAME=fake.user +FAKE_OIDC_GROUPS=bookstack-users From 4b7ff628841fc8ff55c53d4fc08086e76f525fca Mon Sep 17 00:00:00 2001 From: Holger Heidkamp Date: Tue, 24 Mar 2026 13:49:43 +0100 Subject: [PATCH 5/8] Create .dockerignore --- .dockerignore | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..c441b4a6512 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +node_modules +vendor +public/uploads +storage/logs +storage/backups +storage/uploads +.env From 11661639faeb2d7eb4c8e2c283a10e8c88bca021 Mon Sep 17 00:00:00 2001 From: Holger Heidkamp Date: Tue, 24 Mar 2026 13:53:55 +0100 Subject: [PATCH 6/8] Update .dockerignore --- .dockerignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index c441b4a6512..d8e07ef7c48 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,12 @@ .git +.gitignore +.dockerignore node_modules vendor public/uploads +public/uploads/ +public/uploads/** storage/logs storage/backups storage/uploads -.env +.env \ No newline at end of file From b3648df7ae09e4709e12e3dc6147bf6105953e3d Mon Sep 17 00:00:00 2001 From: Holger Heidkamp Date: Tue, 24 Mar 2026 13:55:32 +0100 Subject: [PATCH 7/8] Update .dockerignore --- .dockerignore | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index d8e07ef7c48..19d4faa7342 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,20 @@ .git -.gitignore -.dockerignore +.github node_modules vendor +.env public/uploads public/uploads/ public/uploads/** +storage/framework +storage/framework/ +storage/framework/** storage/logs +storage/logs/ +storage/logs/** storage/backups -storage/uploads -.env \ No newline at end of file +storage/backups/ +storage/backups/** +bootstrap/cache +bootstrap/cache/ +bootstrap/cache/** \ No newline at end of file From 2f8bb5ff63cf0195dd2adb2d5a68a5fa2cd372f6 Mon Sep 17 00:00:00 2001 From: Holger Heidkamp Date: Tue, 24 Mar 2026 14:18:50 +0100 Subject: [PATCH 8/8] Fix multipleauth --- app/Users/Controllers/UserController.php | 3 ++- composer.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index 9810db26b78..fc00f006ad0 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -81,6 +81,7 @@ public function store(Request $request) $sendInvite = ($request->get('send_invite', 'false') === 'true'); $externalAuth = array_intersect(auth_methods(), ['ldap', 'saml2', 'oidc']) !== []; + $externalAuthRequired = $externalAuth && !auth_method_enabled('standard'); $passwordRequired = (auth_method_enabled('standard') && !$sendInvite); $validationRules = [ @@ -91,7 +92,7 @@ public function store(Request $request) 'roles.*' => ['integer'], 'password' => $passwordRequired ? ['required', Password::default()] : null, 'password-confirm' => $passwordRequired ? ['required', 'same:password'] : null, - 'external_auth_id' => $externalAuth ? ['required'] : null, + 'external_auth_id' => $externalAuth ? ($externalAuthRequired ? ['required'] : ['nullable', 'string']) : null, ]; $validated = $this->validate($request, array_filter($validationRules)); diff --git a/composer.json b/composer.json index 6040881669d..be9b2e9efcf 100644 --- a/composer.json +++ b/composer.json @@ -88,6 +88,7 @@ "@php artisan key:generate --ansi" ], "pre-install-cmd": [ + "@php -r \"!file_exists('bootstrap/cache/packages.php') || @unlink('bootstrap/cache/packages.php');\"", "@php -r \"!file_exists('bootstrap/cache/services.php') || @unlink('bootstrap/cache/services.php');\"" ], "post-install-cmd": [