Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.git
.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/backups/
storage/backups/**
bootstrap/cache
bootstrap/cache/
bootstrap/cache/**
48 changes: 48 additions & 0 deletions .env.docker-dev
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions .env.example.complete
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 55 additions & 12 deletions app/Access/Controllers/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand All @@ -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,
]);
}

Expand All @@ -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)) {
Expand All @@ -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');
}

Expand All @@ -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';
}

/**
Expand Down Expand Up @@ -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')
);
}
Expand All @@ -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'];
Expand All @@ -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<int, string>
*/
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.
*/
Expand Down
2 changes: 1 addition & 1 deletion app/Access/Controllers/RegisterController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion app/Access/Controllers/ResetPasswordController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/Access/LdapService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
60 changes: 50 additions & 10 deletions app/Access/LoginService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -198,26 +205,59 @@ 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) {
return false;
}

$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();
}
}
7 changes: 2 additions & 5 deletions app/Access/RegistrationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion app/Access/SocialAuthService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}

Expand Down
Loading