Skip to content
Merged
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
14 changes: 9 additions & 5 deletions Domain/Access/ScheduleUserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@ public function GetUser($userId);

class ScheduleUserRepository implements IScheduleUserRepository
{
public function __construct(private readonly bool $includeAdminPermissions = true)
{
}

public function GetUser($userId)
{
$userPermissions = $this->GetUserPermissions($userId);
return new ScheduleUser(
$userId,
$userPermissions['full'],
$userPermissions['view'],
$this->GetGroupPermissions($userId),
$this->GetGroupAdminPermissions($userId)
userId: $userId,
userPermissionsFull: $userPermissions['full'],
userPermissionsView: $userPermissions['view'],
groupPermissions: $this->GetGroupPermissions($userId),
groupAdminPermissions: $this->includeAdminPermissions ? $this->GetGroupAdminPermissions($userId) : []
);
}

Expand Down
25 changes: 25 additions & 0 deletions Domain/Access/UserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ public function LoadByUsername($userName);
*/
public function Update(User $user);

/**
* Persists only the data changed by a login: last login time, the language chosen at
* login and, if the user had none, the generated public id. Leaves all other columns
* untouched.
*
* @param string|null $language language chosen at login, or null to keep the stored language
*/
public function UpdateLoginData(User $user, ?string $language): void;

/**
* @param User $user
* @return int
Expand Down Expand Up @@ -551,6 +560,22 @@ public function Update(User $user)
$this->_cache->Remove($userId);
}

public function UpdateLoginData(User $user, ?string $language): void
{
// An existing public_id is never overwritten. If two first logins race, each may
// generate an id and only the first is stored; the other session then holds an
// id that was never persisted. The session's PublicId has no consumer that relies
// on it, so this is accepted rather than adding a read-back query to every login.
ServiceLocator::GetDatabase()->Execute(new UpdateLoginDataCommand(
userId: $user->Id(),
lastLoginTime: $user->LastLogin(),
language: $language,
publicId: $user->GetPublicId()
));

$this->_cache->Remove($user->Id());
}

public function DeleteById($userId)
{
$deleteUserCommand = new DeleteUserCommand($userId);
Expand Down
20 changes: 0 additions & 20 deletions Pages/LoginPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ public function GetEmailAddress();
*/
public function GetPassword();

/**
* @return bool
*/
public function GetPersistLogin();

public function GetShowRegisterLink();

public function SetShowRegisterLink($value);
Expand Down Expand Up @@ -64,11 +59,6 @@ public function ShowUsernamePrompt($shouldShow);
*/
public function ShowPasswordPrompt($shouldShow);

/**
* @param $shouldShow bool
*/
public function ShowPersistLoginPrompt($shouldShow);

/**
* @param $shouldShow bool
*/
Expand Down Expand Up @@ -171,11 +161,6 @@ public function GetPassword()
return strval($this->GetRawForm(FormKeys::PASSWORD));
}

public function GetPersistLogin()
{
return $this->GetCheckbox(FormKeys::PERSIST_LOGIN);
}

public function GetShowRegisterLink()
{
return $this->GetVar('ShowRegisterLink');
Expand Down Expand Up @@ -284,11 +269,6 @@ public function ShowPasswordPrompt($shouldShow)
$this->Set('ShowPasswordPrompt', $shouldShow);
}

public function ShowPersistLoginPrompt($shouldShow)
{
$this->Set('ShowPersistLoginPrompt', $shouldShow);
}

public function ShowForgotPasswordPrompt($shouldShow)
{
$this->Set('ShowForgotPasswordPrompt', $shouldShow);
Expand Down
23 changes: 20 additions & 3 deletions Pages/ResourceDisplayPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

require_once(ROOT_DIR . 'Presenters/ResourceDisplayPresenter.php');

use LibreBooking\Application\Authentication\UserSessionBuilder;

interface IResourceDisplayPage extends IPage, IActionPage
{
public function DisplayLogin();
Expand All @@ -16,6 +18,13 @@ public function GetResourceId();
*/
public function GetPublicResourceId();

/**
* The public id of the resource being booked, posted by the reservation form.
*
* @return string
*/
public function GetReservationResourcePublicId();

/**
* @return string
*/
Expand Down Expand Up @@ -154,13 +163,15 @@ public function __construct()
new ScheduleRepository(),
new DailyLayoutFactory(),
new GuestUserService(
PluginManager::Instance()->LoadAuthentication(),
new GuestRegistration(
authentication: PluginManager::Instance()->LoadAuthentication(),
registration: new GuestRegistration(
new PasswordEncryption(),
new UserRepository(),
new GuestRegistrationNotificationStrategy(),
new GuestReservationPermissionStrategy($this)
)
),
userRepository: new UserRepository(),
userSessionBuilder: new UserSessionBuilder(roleService: PluginManager::Instance()->LoadAuthorization())
),
new AttributeService(new AttributeRepository(), new GuestPermissionService()),
new ReservationRepository(),
Expand Down Expand Up @@ -240,6 +251,11 @@ public function GetPublicResourceId()
return $this->GetQuerystring(QueryStringKeys::RESOURCE_ID);
}

public function GetReservationResourcePublicId()
{
return $this->GetForm(FormKeys::PUBLIC_RESOURCE_ID);
}

public function GetStartDate()
{
$userTimezone = ServiceLocator::GetServer()->GetUserSession()->Timezone;
Expand All @@ -256,6 +272,7 @@ public function BindResource(BookableResource $resource)
{
$this->Set('ResourceName', $resource->GetName());
$this->Set('ResourceId', $resource->GetId());
$this->Set('PublicResourceId', $resource->GetPublicId());
}

public function DisplayAvailability(IDailyLayout $dailyLayout, Date $today, Date $reservationDate, $current, $next, $upcoming, $requiresCheckin, $checkinReferenceNumber)
Expand Down
19 changes: 1 addition & 18 deletions Presenters/LoginPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,6 @@ public function PageLoad()
return;
}

$server = ServiceLocator::GetServer();
$loginCookie = $server->GetCookie(CookieKeys::PERSIST_LOGIN);

if ($this->IsCookieLogin($loginCookie)) {
if ($this->authentication->CookieLogin($loginCookie, new WebLoginContext(new LoginData(true)))) {
$this->_Redirect();
return;
}
}

$allowRegistration = Configuration::Instance()->GetKey(ConfigKeys::REGISTRATION_ALLOW_SELF, new BooleanConverter());
$allowAnonymousSchedule = Configuration::Instance()->GetKey(ConfigKeys::PRIVACY_VIEW_SCHEDULES, new BooleanConverter());
$allowGuestBookings = Configuration::Instance()->GetKey(ConfigKeys::PRIVACY_ALLOW_GUEST_RESERVATIONS, new BooleanConverter());
Expand All @@ -114,15 +104,13 @@ public function PageLoad()
$showForgot = (!$resetDisabled && $this->authentication->ShowForgotPasswordPrompt() && !$hideLogin);

$showPasswordPrompt = ($this->authentication->ShowPasswordPrompt() && !$hideLogin);
$showPersistLogin = ($this->authentication->ShowPersistLoginPrompt() && !$hideLogin);
$showUsernamePrompt = ($this->authentication->ShowUsernamePrompt() && !$hideLogin);

$this->_page->SetShowRegisterLink($showRegisterLink);
$this->_page->SetShowScheduleLink($showScheduleLink);

$this->_page->ShowForgotPasswordPrompt($showForgot);
$this->_page->ShowPasswordPrompt($showPasswordPrompt);
$this->_page->ShowPersistLoginPrompt($showPersistLogin);
$this->_page->ShowUsernamePrompt($showUsernamePrompt);
$registrationUrl = $this->authentication->GetRegistrationUrl();
$this->_page->SetRegistrationUrl($showRegisterLink ? $registrationUrl : null);
Expand Down Expand Up @@ -163,7 +151,7 @@ public function Login()
}

if ($isValid) {
$context = new WebLoginContext(new LoginData($this->_page->GetPersistLogin(), $this->_page->GetSelectedLanguage()));
$context = new WebLoginContext(new LoginData(language: $this->_page->GetSelectedLanguage()));
$this->authentication->Login($id, $context);
$this->_Redirect();
} else {
Expand Down Expand Up @@ -238,11 +226,6 @@ private function _Redirect()
LoginRedirector::Redirect($this->_page);
}

private function IsCookieLogin($loginCookie)
{
return !empty($loginCookie);
}

private function SetSelectedLanguage()
{
$requestedLanguage = $this->_page->GetRequestedLanguage();
Expand Down
75 changes: 60 additions & 15 deletions Presenters/ResourceDisplayPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class ResourceDisplayPresenter extends ActionPresenter
private $guestUserService;

/**
* @var IReservationHandler
* @var IReservationHandler|null
*/
public $reservationCreateHandler;

Expand Down Expand Up @@ -243,8 +243,21 @@ public function Reserve()
return;
}

$resultCollector = new ReservationResultCollector();

// The resource is identified by its public id and must be enabled for display, the
// same requirement as viewing it here. This is checked before any guest account or
// reservation is created, so a request for any other resource has no effect. An
// internal resource id from the request is never used.
$resource = $this->LoadDisplayEnabledResource($this->page->GetReservationResourcePublicId());
if ($resource == null) {
$resultCollector->SetSaveSuccessfulMessage(false);
$resultCollector->SetErrors(['Unauthorized']);
$this->page->SetReservationSaveResults(false, $resultCollector);
return;
}

$timezone = $this->page->GetTimezone();
$resourceId = $this->page->GetResourceId();
$email = $this->page->GetEmail();

$reservationDate = Date::Parse($this->page->GetBeginDate(), $timezone)->Format('Y-m-d ');
Expand All @@ -256,31 +269,63 @@ public function Reserve()
}
$maxDate = Date::Now()->ToTimezone($timezone)->AddDays($maxFutureDays + 1)->GetDate();

$resultCollector = new ReservationResultCollector();

if ($date->GetBegin()->GreaterThan($maxDate)) {
$resultCollector->SetSaveSuccessfulMessage(false);
$resultCollector->SetErrors(['Unauthorized']);
$success = false;
} else {
$this->page->SetReservationSaveResults(false, $resultCollector);
return;
}

$userSession = $this->guestUserService->CreateOrLoad($email);
$resource = $this->resourceRepository->LoadById($resourceId);
$userSession = $this->guestUserService->CreateOrLoad($email);

$series = ReservationSeries::Create($userSession->UserId, $resource, Resources::GetInstance()->GetString('AdHocMeeting'), Resources::GetInstance()->GetString('AdHocMeeting'), $date, new RepeatNone(), $userSession);
if (!$userSession->IsLoggedIn()) {
$resultCollector->SetSaveSuccessfulMessage(false);
$resultCollector->SetErrors([Resources::GetInstance()->GetString('RegisteredAccountRequired')]);
$this->page->SetReservationSaveResults(false, $resultCollector);
return;
}

$series->AcceptTerms($this->page->GetTermsOfServiceAcknowledgement());
// An email identifies the owner; it does not authenticate their admin privileges.
// Restrict both existing accounts and sessions returned after guest registration.
$userSession = $userSession->WithoutAdminPrivileges();

$attributes = $this->page->GetAttributes();
foreach ($attributes as $attribute) {
$series->AddAttributeValue(new AttributeValue($attribute->Id, $attribute->Value));
}
$series = ReservationSeries::Create($userSession->UserId, $resource, Resources::GetInstance()->GetString('AdHocMeeting'), Resources::GetInstance()->GetString('AdHocMeeting'), $date, new RepeatNone(), $userSession);

$series->AcceptTerms($this->page->GetTermsOfServiceAcknowledgement());

$success = $this->GetHandler($userSession)->Handle($series, $resultCollector);
$attributes = $this->page->GetAttributes();
foreach ($attributes as $attribute) {
$series->AddAttributeValue(new AttributeValue($attribute->Id, $attribute->Value));
}

$success = $this->GetHandler($userSession)->Handle($series, $resultCollector);
$this->page->SetReservationSaveResults($success, $resultCollector);
}

/**
* Loads the resource to book from the tablet view by its public id. Returns null when the
* public id is missing or unknown, or the resource is not enabled for display. Enablement
* is the same test DisplayResource() applies, so only a resource the tablet can show can
* be booked from it; an internal resource id is never accepted.
*
* @param string|null $publicResourceId
* @return BookableResource|null
*/
private function LoadDisplayEnabledResource($publicResourceId)
{
if (empty($publicResourceId)) {
return null;
}

$resource = $this->resourceRepository->LoadByPublicId($publicResourceId);

if (empty($resource->GetId()) || !$resource->GetIsCalendarSubscriptionAllowed()) {
return null;
}

return $resource;
}

public function Checkin()
{
$resultCollector = new ReservationResultCollector();
Expand Down
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,26 @@
# Librebooking

> [!WARNING]
> We are planning on doing a security release for a high-impact security
> vulnerability. The release is planned to happen on [Tuesday 13-October-2026 @
> 9AM PDT (Pacific Daylight Time)](https://www.timeanddate.com/worldclock/meetingdetails.html?year=2026&month=10&day=13&hour=16&min=0&sec=0&p1=5306).
> **Security: upgrade to LibreBooking 7.1.0 or later immediately.**
>
> You can convert to your local time zone here:
> [https://www.timeanddate.com/worldclock/meetingdetails.html?year=2026&month=10&day=13&hour=16&min=0&sec=0&p1=5306](https://www.timeanddate.com/worldclock/meetingdetails.html?year=2026&month=10&day=13&hour=16&min=0&sec=0&p1=5306)
> All versions before 7.1.0 contain a critical vulnerability that lets an
> unauthenticated attacker sign in as another user, including an
> administrator, by forging the "Remember Me" login cookie. Security fixes
> are not backported, so no fix will be released for 7.0.x or earlier.
>
> If you have questions/comments feel free to join the
> [Discord channel](https://discord.gg/4TGThPtmX8) or comment on the
> [related issue](https://github.com/LibreBooking/librebooking/issues/1779)
> Though we will not disclose what the issue is until the release.
> LibreBooking 7.1.0 removes the "Remember Me" option. Users sign in again
> once their session ends.
>
> Regular backups are also recommended in case of any issues.
> If you cannot upgrade right away, configure your web server or reverse
> proxy to remove the `persist_login` cookie from incoming requests. See the
> [security advisory](https://github.com/LibreBooking/librebooking/security/advisories/GHSA-mvjx-72w3-wqhp)
> for details and for how to check whether an installation was compromised,
> and get the
> [latest release](https://github.com/LibreBooking/librebooking/releases/latest).
>
> As always we invite people to join the [Discord channel](https://discord.gg/4TGThPtmX8)
> If you have questions, join the
> [Discord channel](https://discord.gg/4TGThPtmX8) or comment on the
> [related issue](https://github.com/LibreBooking/librebooking/issues/1779).

Previous security update

Expand Down
Loading
Loading