diff --git a/Pages/LoginPage.php b/Pages/LoginPage.php index e3df15d58..4211ad5f4 100644 --- a/Pages/LoginPage.php +++ b/Pages/LoginPage.php @@ -112,6 +112,12 @@ public function SetFacebookUrl($URL); * */ public function SetKeycloakUrl($URL); + + /** + * + */ + public function SetOauth2Url($URL); + public function SetOauth2Name($Name); } class LoginPage extends Page implements ILoginPage @@ -134,6 +140,7 @@ public function __construct() $this->Set('AllowGoogleLogin', Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::AUTHENTICATION_ALLOW_GOOGLE, new BooleanConverter())); $this->Set('AllowMicrosoftLogin', Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::AUTHENTICATION_ALLOW_MICROSOFT, new BooleanConverter())); $this->Set('AllowKeycloakLogin', Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::AUTHENTICATION_ALLOW_KEYCLOAK, new BooleanConverter())); + $this->Set('AllowOauth2Login', Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::AUTHENTICATION_ALLOW_OAUTH2, new BooleanConverter())); $scriptUrl = Configuration::Instance()->GetScriptUrl(); $parts = explode('://', $scriptUrl); $this->Set('Protocol', $parts[0]); @@ -353,4 +360,18 @@ public function SetKeycloakUrl($KeycloakUrl) $this->Set('KeycloakUrl', $KeycloakUrl); } } + + public function SetOauth2Url($Oauth2Url) + { + if (Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::AUTHENTICATION_ALLOW_OAUTH2, new BooleanConverter())) { + $this->Set('Oauth2Url', $Oauth2Url); + } + } + + public function SetOauth2Name($Oauth2Name) + { + if (Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::AUTHENTICATION_ALLOW_OAUTH2, new BooleanConverter())) { + $this->Set('Oauth2Name', $Oauth2Name); + } + } } diff --git a/Presenters/Authentication/ExternalAuthLoginPresenter.php b/Presenters/Authentication/ExternalAuthLoginPresenter.php index abaa24394..54282c363 100644 --- a/Presenters/Authentication/ExternalAuthLoginPresenter.php +++ b/Presenters/Authentication/ExternalAuthLoginPresenter.php @@ -38,6 +38,9 @@ public function PageLoad() if ($this->page->GetType() == 'keycloak') { $this->ProcessKeycloakSingleSignOn(); } + if ($this->page->GetType() == 'oauth2') { + $this->ProcessOauth2SingleSignOn(); + } } /** @@ -228,6 +231,72 @@ private function ProcessKeycloakSingleSignOn() $this->processUserData($username, $email, $firstName, $lastName, $phone, $organization, $title); } + private function ProcessOauth2SingleSignOn() + { + $code = $_GET['code']; + + $oauth2UrlToken = Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::OAUTH2_URL_TOKEN); + $oauth2UrlUserinfo = Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::OAUTH2_URL_USERINFO); + $clientId = Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::OAUTH2_CLIENT_ID); + $clientSecret = Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::OAUTH2_CLIENT_SECRET); + $redirectUri = rtrim(Configuration::Instance()->GetScriptUrl(), 'Web/') . Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::OAUTH2_REDIRECT_URI); + + // Prepare the POST data for the token request. + $postData = [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $redirectUri, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]; + + $client = new \GuzzleHttp\Client(); + + try { + $response = $client->post($oauth2UrlToken, [ + 'form_params' => $postData, + ]); + } catch (\Exception $e) { + $this->page->ShowError(['Error retrieving Oauth2 token: ' . $e->getMessage()]); + return; + } + + $tokenData = json_decode($response->getBody(), true); + if (!isset($tokenData['access_token'])) { + $this->page->ShowError(['Access token not found in Oauth2 response']); + return; + } + $accessToken = $tokenData['access_token']; + + try { + $userResponse = $client->get($oauth2UrlUserinfo, [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $accessToken, + ], + ]); + } catch (\Exception $e) { + $this->page->ShowError(['Error retrieving Oauth2 user info: ' . $e->getMessage()]); + return; + } + + $userData = json_decode($userResponse->getBody(), true); + + $email = isset($userData['email']) ? $userData['email'] : ''; + $firstName = isset($userData['given_name']) ? $userData['given_name'] : 'not set'; + $lastName = isset($userData['family_name']) ? $userData['family_name'] : 'not set'; + $username = isset($userData['preferred_username']) ? $userData['preferred_username'] : $email; + $phone = isset($userData['phone_number']) ? $userData['phone_number'] : ''; + $organization = isset($userData['organization']) ? $userData['organization'] : ''; + $title = isset($userData['title']) ? $userData['title'] : ''; + + if (empty($email)) { + $this->page->ShowError(["Email is not set in your Oauth2 profile. Please update your profile and try again."]); + return; + } + + $this->processUserData($username, $email, $firstName, $lastName, $phone, $organization, $title); + } + /** * Processes user given data, creates a user in database if it doesn't exist and logs it in diff --git a/Presenters/LoginPresenter.php b/Presenters/LoginPresenter.php index ee60a80b6..1522b19fc 100644 --- a/Presenters/LoginPresenter.php +++ b/Presenters/LoginPresenter.php @@ -127,6 +127,8 @@ public function PageLoad() $this->_page->SetMicrosoftUrl($this->GetMicrosoftUrl()); $this->_page->SetFacebookUrl($this->GetFacebookUrl()); $this->_page->SetKeycloakUrl($this->GetKeycloakUrl()); + $this->_page->SetOauth2Url($this->GetOauth2Url()); + $this->_page->SetOauth2Name($this->GetOauth2Name()); } public function Login() @@ -318,4 +320,33 @@ public function GetKeycloakUrl() return $keycloakUrl; } } + + public function GetOauth2Url() + { + if (Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::AUTHENTICATION_ALLOW_OAUTH2, new BooleanConverter())) { + // Retrieve Oauth2 configuration values + $baseUrl = Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::OAUTH2_URL_AUTHORIZE); + $clientId = Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::OAUTH2_CLIENT_ID); + $redirectUri = rtrim(Configuration::Instance()->GetScriptUrl(), 'Web/') . Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::OAUTH2_REDIRECT_URI); + + // Construct the Oauth2 authentication URL + $Oauth2Url = $baseUrl + . '?client_id=' . urlencode($clientId) + . '&redirect_uri=' . urlencode($redirectUri) + . '&response_type=code' + . '&scope=' . urlencode('openid email profile'); + + return $Oauth2Url; + } + } + + public function GetOauth2Name() + { + if (Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::AUTHENTICATION_ALLOW_OAUTH2, new BooleanConverter())) { + // Retrieve Oauth2 configuration values + $Oauth2Name = Configuration::Instance()->GetSectionKey(ConfigSection::AUTHENTICATION, ConfigKeys::OAUTH2_NAME); + + return $Oauth2Name; + } + } } diff --git a/Web/oauth2-auth.php b/Web/oauth2-auth.php new file mode 100644 index 000000000..2a1f13afd --- /dev/null +++ b/Web/oauth2-auth.php @@ -0,0 +1,14 @@ +/Web/oauth2-auth.php` ex. `https://librebooking.com/Web/oauth2-auth.php` and need the scoopes `openid`, `email` and `profile`. + +The mapping of Oauth2 attributes to LibreBooking attributes is: +- `email` -> `email` +- `given_name` -> `firstName` +- `family_name` -> `lastName` +- `preferred_username` -> `username` +- `phone` -> `phone_number` +- `organization` -> `organization` +- `title` -> `title` + +## LibreBooking Config +To connect LibreBooking with your Oauth2 IdP you need to add the following vars to your `config.php`, in this example with authentik as IdP with the url `authentik.io`. +```php +$conf['settings']['authentication']['allow.oauth2.login'] = 'true'; // Enable Oauth2 +$conf['settings']['authentication']['oauth2.name'] = 'authentik'; // Display name of Oauth2 IdP +$conf['settings']['authentication']['oauth2.url.authorize'] = 'https://authentik.io/application/o/authorize/'; // Oauth2 Authorization Endpoint +$conf['settings']['authentication']['oauth2.url.token'] = 'https://authentik.io/application/o/token/'; // Oauth2 Token Endpoint +$conf['settings']['authentication']['oauth2.url.userinfo'] = 'https://authentik.io/application/o/userinfo/'; // Oauth2 Userinfo Endpoint +$conf['settings']['authentication']['oauth2.client.id'] = 'c3zzBXq9Qw3K9KErd9ta6tQgvVhr6wT3rkQaInz8'; +$conf['settings']['authentication']['oauth2.client.secret'] = '13246zgtfd4t456zhg8rdgf98g789df7gFG56z5zhb'; +``` + +To hide the internal LibreBooking Login you can additional add the following variable. +```php +$conf['settings']['authentication']['hide.booked.login.prompt'] = 'true'; +``` diff --git a/lib/Config/ConfigKeys.php b/lib/Config/ConfigKeys.php index ec6a6cc21..d42d375e2 100644 --- a/lib/Config/ConfigKeys.php +++ b/lib/Config/ConfigKeys.php @@ -147,6 +147,7 @@ class ConfigKeys public const AUTHENTICATION_ALLOW_GOOGLE = 'allow.google.login'; public const AUTHENTICATION_ALLOW_MICROSOFT = 'allow.microsoft.login'; public const AUTHENTICATION_ALLOW_KEYCLOAK = 'allow.keycloak.login'; + public const AUTHENTICATION_ALLOW_OAUTH2 = 'allow.oauth2.login'; public const AUTHENTICATION_REQUIRED_EMAIL_DOMAINS = 'required.email.domains'; public const AUTHENTICATION_HIDE_BOOKED_LOGIN_PROMPT = 'hide.booked.login.prompt'; public const AUTHENTICATION_CAPTCHA_ON_LOGIN = 'captcha.on.login'; @@ -187,6 +188,15 @@ class ConfigKeys public const KEYCLOAK_CLIENT_ID = 'keycloak.client.id'; public const KEYCLOAK_CLIENT_SECRET = 'keycloak.client.secret'; public const KEYCLOAK_REDIRECT_URI = 'keycloak.client.uri'; + + public const OAUTH2_NAME = 'oauth2.name'; + public const OAUTH2_URL_AUTHORIZE = 'oauth2.url.authorize'; + public const OAUTH2_URL_TOKEN = 'oauth2.url.token'; + public const OAUTH2_URL_USERINFO = 'oauth2.url.userinfo'; + public const OAUTH2_CLIENT_ID = 'oauth2.client.id'; + public const OAUTH2_CLIENT_SECRET = 'oauth2.client.secret'; + public const OAUTH2_REDIRECT_URI = 'oauth2.client.uri'; + public const YEARS_OLD_DATA = 'years.old.data'; public const DELETE_OLD_ANNOUNCEMENTS = 'delete.old.announcements'; public const DELETE_OLD_BLACKOUTS = 'delete.old.blackouts'; diff --git a/tests/Presenters/LoginPresenterTest.php b/tests/Presenters/LoginPresenterTest.php index 6596a0b28..a053eedeb 100644 --- a/tests/Presenters/LoginPresenterTest.php +++ b/tests/Presenters/LoginPresenterTest.php @@ -231,6 +231,10 @@ public function SetFacebookUrl($URL) {} public function SetKeycloakUrl($URL) {} + public function SetOauth2Url($URL) {} + + public function SetOauth2Name($Name) {} + public function PageLoad() { $this->_PageLoadWasCalled = true; diff --git a/tpl/login.tpl b/tpl/login.tpl index 0c629b956..9b95630ee 100644 --- a/tpl/login.tpl +++ b/tpl/login.tpl @@ -97,6 +97,10 @@ {translate key='SignInWith'} Keycloak {/if} + {if $AllowOauth2Login} + {translate key='SignInWith'} + {$Oauth2Name} + {/if} {if $facebookError}