diff --git a/.gitignore b/.gitignore index 3981b1fce150ddd5381214510586fb3de4f6b63b..c014544fac7bec4aa260424a239cd448dadaeb9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ composer.lock config.php +config.json logs/ vendor/ -nbproject/ +client-icons/ +/nbproject/ +*.pem +/build/ +.phpunit.result.cache diff --git a/.htaccess b/.htaccess index 0654970d0361d0925f2a271bf72101b1c16784c9..c59e2c4287f2112150acb12814705eae00ed4148 100644 --- a/.htaccess +++ b/.htaccess @@ -3,3 +3,12 @@ RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)$ index.php [QSA,L] + +# mod_rewrite changes some Shibboleth headers +# this restores them: +SetEnvIf REDIRECT_Shib-Session-ID (.+) Shib-Session-ID=$1 +SetEnvIf REDIRECT_eppn (.+) eppn=$1 +SetEnvIf REDIRECT_mail (.+) mail=$1 +SetEnvIf REDIRECT_givenName (.+) givenName=$1 +SetEnvIf REDIRECT_sn (.+) sn=$1 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..98be7a84d85f6ae8f685ceec7f988bdf76be5450 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +FROM ubuntu:18.10 + +# To fix "configuring tzdata" interactive input during apt install +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -yq --no-install-recommends \ + apache2 \ + libapache2-mod-php7.2 \ + php7.2-xml \ + php7.2-mbstring \ + php-mysql \ + php-curl \ + libapache2-mod-shib2 \ + make \ + wget \ + ca-certificates \ + ssl-cert \ + vim + +# Copying Shibboleth SP configuration +COPY docker/shibboleth/shibboleth2.xml /etc/shibboleth/ +COPY docker/shibboleth/sp-key.pem /etc/shibboleth/ +COPY docker/shibboleth/sp-cert.pem /etc/shibboleth/ + +# Installing Embedded Discovery Service +WORKDIR /usr/local/src + +RUN wget https://shibboleth.net/downloads/embedded-discovery-service/1.2.1/shibboleth-embedded-ds-1.2.1.tar.gz -O shibboleth-eds.tar.gz +RUN tar xzf shibboleth-eds.tar.gz + +WORKDIR shibboleth-embedded-ds-1.2.1 +RUN make install + +RUN mv /etc/shibboleth-ds/shibboleth-ds.conf /etc/apache2/conf-available/shibboleth-ds.conf +RUN sed -i 's/Allow from All/Require all granted/g' /etc/apache2/conf-available/shibboleth-ds.conf +RUN a2enconf shibboleth-ds.conf + +# Adding RAP Apache configuration +COPY docker/rap.conf /etc/apache2/conf-available/ +RUN a2enconf rap.conf + +# Enable mod_rewrite (for Flight framework) +RUN a2enmod rewrite +RUN a2enmod ssl +RUN a2ensite default-ssl + +# Copying RAP php files +WORKDIR /var/www/html +COPY . rap-ia2 + +WORKDIR /var/www/html/rap-ia2 +RUN mkdir -p logs +RUN chown -R www-data logs + +# Starting shibd & Apache +CMD service shibd start && apachectl -D FOREGROUND diff --git a/README.md b/README.md index 6912d4dd51b13a41c66293c1c65ee19e86b6f72b..462ca07ab2fd345a295176f54a21d473458c9174 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Requirements: On Ubuntu: - sudo apt install apache2 mariadb-server libapache2-mod-php mariadb-server + sudo apt install apache2 mariadb-server libapache2-mod-php mariadb-server php7.2-xml php7.2-mbstring php-mysql php-curl ### PHP @@ -108,6 +108,10 @@ Before using social API it is necessary to register an application on each socia Copy the `config-example.php` into `config.php` and edit it for matching your needs. +### Generate keypair + + php exec/generate-keypair.php + ### Logs directory Create the logs directory and assign ownership to the Apache user (usually www-data or apache) @@ -115,6 +119,22 @@ Create the logs directory and assign ownership to the Apache user (usually www-d mkdir logs sudo chown www-data logs +### Run Unit Tests and build code coverage report + +(XDebug or another code coverage driver needs to be installed; e.g. `sudo apt install php-xdebug`) + + ./vendor/bin/phpunit --bootstrap vendor/autoload.php --coverage-html build/coverage-report tests/ + ## Additional information and developer guide See the wiki: https://www.ict.inaf.it/gitlab/zorba/rap-ia2/wikis/home + +## Troubleshooting + +### Class not found while developing + +If you see a message like this: + + PHP Fatal error: Uncaught Error: Class 'RAP\\[...]' not found + +probably you have to regenerate the PHP autoload calling `composer dumpautoload` in RAP root directory. diff --git a/auth/oauth2/facebook_login.php b/auth/oauth2/facebook_login.php deleted file mode 100755 index 844cf9f6a6dc8c677f1f43c1229a89e949d3bda3..0000000000000000000000000000000000000000 --- a/auth/oauth2/facebook_login.php +++ /dev/null @@ -1,46 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -/* This page uses the Facebook API for generating the redirect URL to use for Facebook login */ - -include '../../include/init.php'; -startSession(); - -// Retrieve Facebook configuration -$Facebook = $AUTHENTICATION_METHODS['Facebook']; - -$fb = new Facebook\Facebook([ - 'app_id' => $Facebook['id'], - 'app_secret' => $Facebook['secret'], - 'default_graph_version' => $Facebook['version'], - ]); - -$helper = $fb->getRedirectLoginHelper(); - -$permissions = ['email']; // Optional permissions: we need user email - -$loginUrl = $helper->getLoginUrl($Facebook['callback'], $permissions); - -header("Location: $loginUrl"); -?> diff --git a/auth/oauth2/facebook_token.php b/auth/oauth2/facebook_token.php deleted file mode 100755 index ef7446143aea2c92d8cf87bcd2bcbe322a60bc0e..0000000000000000000000000000000000000000 --- a/auth/oauth2/facebook_token.php +++ /dev/null @@ -1,111 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -/* Facebook callback page */ - -include '../../include/init.php'; -startSession(); - -// Retrieve Facebook configuration -$Facebook = $AUTHENTICATION_METHODS['Facebook']; - -$fb = new Facebook\Facebook([ - 'app_id' => $Facebook['id'], - 'app_secret' => $Facebook['secret'], - 'default_graph_version' => $Facebook['version'], - ]); - -$helper = $fb->getRedirectLoginHelper(); -if (isset($_GET['state'])) { - $helper->getPersistentDataHandler()->set('state', $_GET['state']); -} - -try { - // obtaining current URL without query string - $url = "https://$_SERVER[HTTP_HOST]" . strtok($_SERVER["REQUEST_URI"], '?'); - $accessToken = $helper->getAccessToken($url); -} catch (Facebook\Exceptions\FacebookResponseException $e) { - // When Graph returns an error - http_response_code(500); - die('Graph returned an error: ' . $e->getMessage()); -} catch (Facebook\Exceptions\FacebookSDKException $e) { - // When validation fails or other local issues - http_response_code(500); - die('Facebook SDK returned an error: ' . $e->getMessage()); -} -if (!isset($accessToken)) { - if ($helper->getError()) { - $errorMessage = "Error: " . $helper->getError() . "<br>"; - $errorMessage = $errorMessage . "Error Code: " . $helper->getErrorCode() . "<br>"; - $errorMessage = $errorMessage . "Error Reason: " . $helper->getErrorReason() . "<br>"; - $errorMessage = $errorMessage . "Error Description: " . $helper->getErrorDescription(); - } else { - $errorMessage = "Bad request"; - } - - http_response_code(500); - die($errorMessage); -} - -try { - // Returns a `Facebook\FacebookResponse` object - $response = $fb->get('/me?fields=id,first_name,last_name,email', $accessToken); -} catch (Facebook\Exceptions\FacebookResponseException $e) { - echo 'Graph returned an error: ' . $e->getMessage(); - exit; -} catch (Facebook\Exceptions\FacebookSDKException $e) { - echo 'Facebook SDK returned an error: ' . $e->getMessage(); - exit; -} - -$_SESSION['fb_access_token'] = (string) $accessToken; - -$fbUser = $response->getGraphUser(); - -$typedId = $fbUser["id"]; - -// Search if the user is already registered into RAP using the Facebook ID. -$user = $userHandler->findUserByIdentity(RAP\Identity::FACEBOOK, $typedId); - -if ($user === null) { - // Create new user - $user = new RAP\User(); - - $identity = new RAP\Identity(RAP\Identity::FACEBOOK); - $identity->email = $fbUser["email"]; - $identity->name = $fbUser["first_name"]; - $identity->surname = $fbUser["last_name"]; - $identity->typedId = $typedId; - - $user->addIdentity($identity); - - $session->userToLogin = $user; - $session->save(); - header('Location: ' . $BASE_PATH . '/tou-check'); - die(); -} - -$auditLog->info("LOGIN,Facebook," . $user->id); -$callbackHandler->manageLoginRedirect($user, $session); -?> diff --git a/auth/oauth2/google_token.php b/auth/oauth2/google_token.php deleted file mode 100644 index 16831cc69bb0a67df476cc4a2e6a4f29b86d5db8..0000000000000000000000000000000000000000 --- a/auth/oauth2/google_token.php +++ /dev/null @@ -1,111 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -/* Google redirect and callback page */ - -include '../../include/init.php'; -startSession(); - -// Retrieve Google configuration -$Google = $AUTHENTICATION_METHODS['Google']; - -$client = new Google_Client(array( - 'client_id' => $Google['id'], - 'client_secret' => $Google['secret'], - 'redirect_uri' => $Google['callback'], - )); - -// Ask permission to obtain user email and profile information -$client->setScopes(array(Google_Service_People::USERINFO_EMAIL, Google_Service_People::USERINFO_PROFILE)); - -if (isset($_REQUEST['logout'])) { -// Reset the access token stored into the session - unset($_SESSION['access_token']); -} - -if (isset($_GET['code'])) { -// An access token has been returned from the auth URL. - $client->authenticate($_GET['code']); - $_SESSION['access_token'] = $client->getAccessToken(); -} - -//if (isset($_SESSION['access_token'])) { -// $client->setAccessToken($_SESSION['access_token']); -//} - -if ($client->getAccessToken()) { - - // Query web service for retrieving user information - $service = new Google_Service_People($client); - - try { - $res = $service->people->get('people/me', array('requestMask.includeField' => 'person.names,person.email_addresses')); - } catch (Google_Service_Exception $e) { - echo '<p>' . json_encode($e->getErrors()) . '</p>'; - $thisPage = $PROTOCOL . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF']; - echo '<p><a href="' . $thisPage . '?logout">Click here to unset the access token</a></p>'; - } - - $name = $res->getNames()[0]->getGivenName(); - $surname = $res->getNames()[0]->getFamilyName(); - - $emailAddresses = []; - foreach ($res->getEmailAddresses() as $addr) { - array_push($emailAddresses, $addr->value); - } - - $typedId = explode('/', $res->getResourceName())[1]; - - // Search if the user is already registered into RAP using the Google ID. - $user = $userHandler->findUserByIdentity(RAP\Identity::GOOGLE, $typedId); - - if ($user === null) { - // Create new user - $user = new RAP\User(); - - $identity = new RAP\Identity(RAP\Identity::GOOGLE); - $identity->email = $emailAddresses[0]; - $identity->name = $name; - $identity->surname = $surname; - $identity->typedId = $typedId; - - $user->addIdentity($identity); - - $session->userToLogin = $user; - $session->save(); - header('Location: ' . $BASE_PATH . '/tou-check'); - die(); - } - - $auditLog->info("LOGIN,Google," . $user->id); - $callbackHandler->manageLoginRedirect($user, $session); - - die(); -} else { - // Redirect to Google authorization URL for obtaining an access token - $authUrl = $client->createAuthUrl(); - header('Location: ' . $authUrl); - die(); -} -?> diff --git a/auth/oauth2/linkedin_login.php b/auth/oauth2/linkedin_login.php deleted file mode 100644 index ac03db4e5ed33b4aad2be905ecd50260d99b8a47..0000000000000000000000000000000000000000 --- a/auth/oauth2/linkedin_login.php +++ /dev/null @@ -1,40 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -/* This page redirects to LinkedIn login page */ - -include '../../include/init.php'; -startSession(); - -// Retrieve LinkedIn configuration -$LinkedIn = $AUTHENTICATION_METHODS['LinkedIn']; - -$url = "https://www.linkedin.com/oauth/v2/authorization?response_type=code"; -$url .= "&client_id=" . $LinkedIn['id']; -$url .= "&redirect_uri=" . $LinkedIn['callback']; -$url .= "&state=789654123"; -$url .= "&scope=r_liteprofile%20r_emailaddress%20w_member_social"; - -header("Location: $url"); -?> diff --git a/auth/oauth2/linkedin_token.php b/auth/oauth2/linkedin_token.php deleted file mode 100644 index c35a7d248bfa6ca661ae803a7865ae6f3dd025e8..0000000000000000000000000000000000000000 --- a/auth/oauth2/linkedin_token.php +++ /dev/null @@ -1,164 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -/* LinkedIn callback page. Curl is used, because LinkedIn doesn't provide official PHP API. */ - -include '../../include/init.php'; -startSession(); - -// Retrieve LinkedIn configuration -$LinkedIn = $AUTHENTICATION_METHODS['LinkedIn']; - -if (!isset($_REQUEST['code'])) { - die("Unable to get LinkedIn client code"); -} - -//create array of data to be posted to get AccessToken -$post_data = array( - 'grant_type' => "authorization_code", - 'code' => $_REQUEST['code'], - 'redirect_uri' => $LinkedIn['callback'], - 'client_id' => $LinkedIn['id'], - 'client_secret' => $LinkedIn['secret']); - -//traverse array and prepare data for posting (key1=value1) -foreach ($post_data as $key => $value) { - $post_items[] = $key . '=' . $value; -} - -//create the final string to be posted -$post_string = implode('&', $post_items); - -//create cURL connection -$conn1 = curl_init('https://www.linkedin.com/oauth/v2/accessToken'); - -//set options -curl_setopt($conn1, CURLOPT_CONNECTTIMEOUT, 30); -curl_setopt($conn1, CURLOPT_RETURNTRANSFER, true); -curl_setopt($conn1, CURLOPT_SSL_VERIFYPEER, true); -curl_setopt($conn1, CURLOPT_FOLLOWLOCATION, 1); - -//set data to be posted -curl_setopt($conn1, CURLOPT_POSTFIELDS, $post_string); - -//perform our request -$result1 = curl_exec($conn1); -$info1 = curl_getinfo($conn1); - -if ($info1['http_code'] === 200) { - $my_token = json_decode($result1, TRUE); - $access_token = $my_token['access_token']; - $expires_in = $my_token['expires_in']; - curl_close($conn1); -} else { - //show information regarding the error - $errorMessage = "Error: LinkedIn server response code: " . $info1['http_code'] . " - "; - $errorMessage .= curl_error($conn1); - curl_close($conn1); - http_response_code(500); - die($errorMessage); -} - -// Call to API -$conn2 = curl_init(); -curl_setopt($conn2, CURLOPT_URL, "https://api.linkedin.com/v2/me"); -curl_setopt($conn2, CURLOPT_HTTPHEADER, array( - 'Authorization: Bearer ' . $access_token -)); - -curl_setopt($conn2, CURLOPT_RETURNTRANSFER, true); -$result = curl_exec($conn2); -$info2 = curl_getinfo($conn2); - -if ($info2['http_code'] === 200) { - $data = json_decode($result, TRUE); - - curl_close($conn2); - - if (isset($data['errorCode'])) { - $errorMessage = $data['message']; - die($errorMessage); - } - - $typedId = $data['id']; - - // Search if the user is already registered into RAP using the LinkedIn ID. - $user = $userHandler->findUserByIdentity(RAP\Identity::LINKEDIN, $typedId); - - if ($user === null) { - - // Recall to API for email - $conn2 = curl_init(); - curl_setopt($conn2, CURLOPT_URL, "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))"); - curl_setopt($conn2, CURLOPT_HTTPHEADER, array( - 'Authorization: Bearer ' . $access_token - )); - - curl_setopt($conn2, CURLOPT_RETURNTRANSFER, true); - $result = curl_exec($conn2); - $info2 = curl_getinfo($conn2); - - if ($info2['http_code'] === 200) { - $data2 = json_decode($result, TRUE); - - curl_close($conn2); - - if (isset($data['errorCode'])) { - $errorMessage = $data['message']; - die($errorMessage); - } - } else { - //show information regarding the error - $errorMessage = "Error: LinkedIn server response code: " . $info2['http_code'] . " - "; - $errorMessage = $errorMessage . curl_error($conn2); - curl_close($conn2); - die($errorMessage); - } - // Create new user - $user = new RAP\User(); - - $identity = new RAP\Identity(RAP\Identity::LINKEDIN); - $identity->email = $data2['elements'][0]['handle~']['emailAddress']; - $identity->name = $data['localizedFirstName']; - $identity->surname = $data['localizedLastName']; - $identity->typedId = $typedId; - - $user->addIdentity($identity); - - $session->userToLogin = $user; - $session->save(); - header('Location: ' . $BASE_PATH . '/tou-check'); - die(); - } - - $auditLog->info("LOGIN,LinkedIn," . $user->id); - $callbackHandler->manageLoginRedirect($user, $session); -} else { - //show information regarding the error - $errorMessage = "Error: LinkedIn server response code: " . $info2['http_code'] . " - "; - $errorMessage = $errorMessage . curl_error($conn2); - curl_close($conn2); - die($errorMessage); -} -?> diff --git a/auth/saml2/aai.php b/auth/saml2/aai.php deleted file mode 100644 index 0b8277958298ca17ce7f111d99336cdf8c878078..0000000000000000000000000000000000000000 --- a/auth/saml2/aai.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -/* This page MUST be protected by Shibboleth authentication - * On Apache httpd: - * AuthType shibboleth - * ShibRequestSetting requireSession 1 - * Require valid-user - */ - -include '../../include/init.php'; -startSession(); - -if (isset($_SERVER['Shib-Session-ID'])) { - - // Retrieving eduPersonPrincipalName (eppn) - $eppn = $_SERVER['eppn']; - - // Search if the user is already registered into RAP using the eppn. - // The persistent id should be a more appropriate identifier, however at IA2 - // we need to import all INAF user into RAP, even if they will never register, - // and in that case we know only their eppn. - $user = $userHandler->findUserByIdentity(RAP\Identity::EDU_GAIN, $eppn); - - if ($user === null) { - // Creating a new user - $user = new RAP\User(); - - $identity = new RAP\Identity(RAP\Identity::EDU_GAIN); - $identity->email = $_SERVER['mail']; - $identity->name = $_SERVER['givenName']; - $identity->surname = $_SERVER['sn']; - $identity->typedId = $eppn; - $identity->eppn = $eppn; - //$_SERVER['Shib-Identity-Provider'] - - $user->addIdentity($identity); - - $session->userToLogin = $user; - $session->save(); - header('Location: ' . $BASE_PATH . '/tou-check'); - die(); - } - - $auditLog->info("LOGIN,eduGAIN," . $user->id); - $callbackHandler->manageLoginRedirect($user, $session); -} else { - http_response_code(500); - die("Shib-Session-ID not found!"); -} diff --git a/auth/x509/certlogin.php b/auth/x509/certlogin.php deleted file mode 100644 index c811cd83f49fbd3d9df74134901c9fca3f38120c..0000000000000000000000000000000000000000 --- a/auth/x509/certlogin.php +++ /dev/null @@ -1,69 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -/* This page must be protected by client certificate authentication - * On Apache httpd: - * SSLVerifyClient require - * SSLVerifyDepth 10 - * SSLOptions +ExportCertData - */ - -include '../../include/init.php'; -startSession(); - -if (isset($_SERVER['SSL_CLIENT_VERIFY']) && isset($_SERVER['SSL_CLIENT_V_REMAIN']) && - $_SERVER['SSL_CLIENT_VERIFY'] === 'SUCCESS' && $_SERVER['SSL_CLIENT_V_REMAIN'] > 0) { - - $x509Data = RAP\X509Data::parse($_SERVER); - - $user = $userHandler->findUserByIdentity(RAP\Identity::X509, $x509Data->serialNumber); - - if ($user === null) { - /** - * We want to extract name and surname from the X.509 certificate, however X.509 - * puts name and surname together (inside the CN field). - * If name and surname are single words it is possible to retrieve them splitting - * on the space character, otherwise the user has to choose the correct combination. - * In that case partial X.509 data is temporarily stored into the user session and - * the page views/x509-name-surname.php is shown to the user before completing the - * registration, in order to allow him/her selecting the correct name and surname. - */ - if ($x509Data->name === null) { - $session->x509DataToRegister = $x509Data; - $session->save(); - header('Location: ' . $BASE_PATH . '/x509-name-surname'); - } else { - $session->userToLogin = $x509Data->toUser(); - $session->save(); - header('Location: ' . $BASE_PATH . '/tou-check'); - } - die(); - } else { - $auditLog->info("LOGIN,X.509," . $user->id); - $callbackHandler->manageLoginRedirect($user, $session); - } -} else { - http_response_code(500); - die("Unable to verify client certificate"); -} diff --git a/auth/x509/index.php b/auth/x509/index.php new file mode 100644 index 0000000000000000000000000000000000000000..fa7755b39858b0a81183f527d7187652e3596b44 --- /dev/null +++ b/auth/x509/index.php @@ -0,0 +1,19 @@ +<?php + +/* It is necessary to use this index file inside /auth/x509 + * because mod_rewrite (used by the Flight framework to + * create a front controller) changes some of the SSL headers + * and SSL client certificate is not recognized anymore */ + +chdir(dirname(__FILE__)); + +include '../../include/init.php'; +// Session must be started after classes inclusion in order +// to avoid __PHP_Incomplete_Class Object error +session_start(); + +$x509Login = new \RAP\X509Login($locator); +$url = $x509Login->login(); +header("Location: $url"); +die(); + diff --git a/classes/CallbackHandler.php b/classes/CallbackHandler.php deleted file mode 100644 index 7fa4718f3bbc688259837928fd2bd0d627acdb2b..0000000000000000000000000000000000000000 --- a/classes/CallbackHandler.php +++ /dev/null @@ -1,142 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -namespace RAP; - -/** - * Manage callback URL validation and redirection - */ -class CallbackHandler { - - private $dao; - private $basePath; - private $callbacks; - - public function __construct(DAO $dao, $basePath, $callbacks) { - $this->dao = $dao; - $this->basePath = $basePath; - $this->callbacks = $callbacks; - } - - /** - * If a callback URL is not in the configured list we should return null. - */ - public function filterCallbackURL($callbackURL) { - foreach ($this->callbacks as $callback) { - if ($callback['url'] === $callbackURL) { - return $callbackURL; - } - } - return null; - } - - /** - * Each callback has a title and a logo in order to avoid confusion in users - * and show in which application they are logging in using RAP. - * @param type $callbackURL - * @return type the callback title or null if the callback URL is not listed - * in configuration file or it doesn't have a title. - */ - public function getCallbackTitle($callbackURL) { - - foreach ($this->callbacks as $callback) { - if ($callback['url'] === $callbackURL) { - return $callback['title']; - } - } - - return null; - } - - /** - * Each callback has a title and a logo in order to avoid confusion in users - * and show in which application they are logging in using RAP. - * @param type $callbackURL - * @return type the callback logo or null if the callback URL is not listed - * in configuration file or it doesn't have a logo. - */ - public function getCallbackLogo($callbackURL) { - - foreach ($this->callbacks as $callback) { - if ($callback['url'] === $callbackURL) { - if (array_key_exists('logo', $callback)) { - return $callback['logo']; - } else { - return null; - } - } - } - - return null; - } - - /** - * Each callback has a title,a logo and auth in order to avoid confusion in - * user and show in which application they are logging in using RAP. - * @param type $callbackURL - * @return type the callback auth or null if the callback URL is not listed - * in configuration file or it doesn't have a auth. - */ - public function getCallbackAuth($callbackURL) { - - foreach ($this->callbacks as $callback) { - if ($callback['url'] === $callbackURL) { - if (array_key_exists('auth', $callback)) { - return $callback['auth']; - } else { - return null; - } - } - } - - return null; - } - public function manageLoginRedirect($user, SessionData $session) { - - if ($session->getCallbackURL() === null) { - http_response_code(401); - die("Unauthorized callback URL"); - } - - if ($session->getCallbackURL() === $this->basePath . '/') { - // Login in session - $session->user = $user; - $session->save(); - // Return to index - header('Location: ' . $this->basePath); - die(); - } else { - // External login using token - header('Location: ' . $this->getLoginWithTokenURL($user->id, $session->getCallbackURL())); - die(); - } - } - - public function getLoginWithTokenURL($userId, $callbackURL) { - $token = Util::createNewToken(); - $this->dao->createLoginToken($token, $userId); - return $callbackURL . '?token=' . $token; - } - -} diff --git a/classes/ClientAuthChecker.php b/classes/ClientAuthChecker.php new file mode 100644 index 0000000000000000000000000000000000000000..cacb8e3ceee6adb1e253cdcaaa899a76958c240f --- /dev/null +++ b/classes/ClientAuthChecker.php @@ -0,0 +1,47 @@ +<?php + +namespace RAP; + +/** + * RFC 6749 specify that in some situations the client must send an Authorization + * Basic header containing its credentials (access token in the authorization code + * flow and refresh token requests). + */ +class ClientAuthChecker { + + private $locator; + + public function __construct(Locator $locator) { + $this->locator = $locator; + } + + public function validateClientAuth(): void { + + $headers = apache_request_headers(); + + if (!isset($headers['Authorization'])) { + throw new UnauthorizedException("Missing Authorization header"); + } + + $authorizationHeader = explode(" ", $headers['Authorization']); + if ($authorizationHeader[0] === "Basic") { + $basic = explode(':', base64_decode($authorizationHeader[1])); + if (count($basic) !== 2) { + throw new BadRequestException("Malformed Basic-Auth header"); + } + $clientId = $basic[0]; + $clientSecret = $basic[1]; + + $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($clientId); + if ($client === null) { + throw new UnauthorizedException("Client '$clientId' not configured"); + } + if ($clientSecret !== $client->secret) { + throw new UnauthorizedException("Invalid client secret"); + } + } else { + throw new UnauthorizedException("Expected Basic authorization header"); + } + } + +} diff --git a/classes/JWKSHandler.php b/classes/JWKSHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..feffd906d1ac26d6c27e3ae528ffcf8461773efb --- /dev/null +++ b/classes/JWKSHandler.php @@ -0,0 +1,80 @@ +<?php + +namespace RAP; + +use phpseclib\Crypt\RSA; + +/** + * Manages the JWT Key Sets (currently only RSA). + */ +class JWKSHandler { + + private $locator; + + public function __construct(Locator $locator) { + $this->locator = $locator; + } + + public function generateKeyPair() { + + $rsa = new RSA(); + + $rsa->setPrivateKeyFormat(RSA::PRIVATE_FORMAT_PKCS1); + $rsa->setPublicKeyFormat(RSA::PUBLIC_FORMAT_PKCS8); + // Guacamole needs a key of at least 2048 + $result = $rsa->createKey(2048); + + $keyPair = new RSAKeyPair(); + $keyPair->alg = 'RS256'; + $keyPair->privateKey = $result['privatekey']; + $keyPair->publicKey = $result['publickey']; + $keyPair->keyId = bin2hex(random_bytes(8)); + + $dao = $this->locator->getJWKSDAO(); + $dao->insertRSAKeyPair($keyPair); + + return $keyPair; + } + + public function getJWKS() { + + $dao = $this->locator->getJWKSDAO(); + + $keyPairs = $dao->getRSAKeyPairs(); + + $keys = []; + foreach ($keyPairs as $keyPair) { + + $rsa = new RSA(); + $rsa->loadKey($keyPair->publicKey); + $rsa->setPublicKey(); + $publicKeyXML = $rsa->getPublicKey(RSA::PUBLIC_FORMAT_XML); + + $rsaModulus = $this->getTagContent($publicKeyXML, "Modulus"); + $rsaExponent = $this->getTagContent($publicKeyXML, "Exponent"); + + $urisafeModulus = strtr($rsaModulus, '+/', '-_'); + + $jwk = []; + $jwk['kty'] = "RSA"; + $jwk['kid'] = $keyPair->keyId; + $jwk['use'] = "sig"; + $jwk['n'] = $urisafeModulus; + $jwk['e'] = $rsaExponent; + + array_push($keys, $jwk); + } + + return [ + "keys" => $keys + ]; + } + + private function getTagContent(string $publicKeyXML, string $tagname): string { + $matches = []; + $pattern = "#<\s*?$tagname\b[^>]*>(.*?)</$tagname\b[^>]*>#s"; + preg_match($pattern, $publicKeyXML, $matches); + return $matches[1]; + } + +} diff --git a/classes/Locator.php b/classes/Locator.php new file mode 100644 index 0000000000000000000000000000000000000000..d032f70b14cbb85d0c17ab8eeed2441df8935fad --- /dev/null +++ b/classes/Locator.php @@ -0,0 +1,152 @@ +<?php + +namespace RAP; + +/** + * Class implementing the locator pattern in order to implement a rough dependency injection. + */ +class Locator { + + public $config; + private $serviceLogger; + private $auditLogger; + private $session; + private $version; + + public function __construct($config) { + $this->config = $config; + + $this->setupLoggers(); + $this->version = file_get_contents(ROOT . '/version.txt'); + } + + public function getVersion(): string { + return $this->version; + } + + public function getProtocol(): string { + return (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https://' : 'http://'; + } + + public function getBasePath(): string { + return $this->getProtocol() . $_SERVER['HTTP_HOST'] . $this->config->contextRoot; + } + + public function getUserDAO(): UserDAO { + $databaseConfig = $this->config->databaseConfig; + switch ($databaseConfig->dbtype) { + case 'MySQL': + return new MySQLUserDAO($this); + default: + throw new \Exception($databaseConfig->dbtype . ' not supported yet'); + } + } + + public function getOAuth2ClientDAO(): OAuth2ClientDAO { + $databaseConfig = $this->config->databaseConfig; + switch ($databaseConfig->dbtype) { + case 'MySQL': + return new MySQLOAuth2ClientDAO($this); + default: + throw new \Exception($databaseConfig->dbtype . ' not supported yet'); + } + } + + public function getJWKSDAO(): JWKSDAO { + $databaseConfig = $this->config->databaseConfig; + switch ($databaseConfig->dbtype) { + case 'MySQL': + return new MySQLJWKSDAO($this); + default: + throw new \Exception($databaseConfig->dbtype . ' not supported yet'); + } + } + + public function getAccessTokenDAO(): AccessTokenDAO { + $databaseConfig = $this->config->databaseConfig; + switch ($databaseConfig->dbtype) { + case 'MySQL': + return new MySQLAccessTokenDAO($this); + default: + throw new \Exception($databaseConfig->dbtype . ' not supported yet'); + } + } + + public function getRefreshTokenDAO(): RefreshTokenDAO { + $databaseConfig = $this->config->databaseConfig; + switch ($databaseConfig->dbtype) { + case 'MySQL': + return new MySQLRefreshTokenDAO($this); + default: + throw new \Exception($databaseConfig->dbtype . ' not supported yet'); + } + } + + public function getCallbackHandler(): CallbackHandler { + return new CallbackHandler($this); + } + + public function getUserHandler(): UserHandler { + return new UserHandler($this); + } + + public function getMailSender(): MailSender { + return new MailSender($_SERVER['HTTP_HOST'], $this->getBasePath()); + } + + public function getOAuth2RequestHandler(): OAuth2RequestHandler { + return new OAuth2RequestHandler($this); + } + + public function getTokenBuilder(): TokenBuilder { + return new TokenBuilder($this); + } + + public function getTokenChecker(): TokenChecker { + return new TokenChecker($this); + } + + public function getClientAuthChecker(): ClientAuthChecker { + return new ClientAuthChecker($this); + } + + /** + * Retrieve the SessionData object from the $_SESSION PHP variable. Create a + * new one if it is necessary. + */ + public function getSession(): SessionData { + if (isset($_SESSION[\RAP\SessionData::KEY])) { + $this->session = $_SESSION[SessionData::KEY]; + } else { + $this->session = new \RAP\SessionData(); + $this->session->save(); + } + return $this->session; + } + + public function getServiceLogger(): \Monolog\Logger { + return $this->serviceLogger; + } + + public function getAuditLogger(): \Monolog\Logger { + return $this->auditLogger; + } + + public function getJWKSHandler(): JWKSHandler { + return new JWKSHandler($this); + } + + private function setupLoggers() { + // Monolog require timezone to be set + date_default_timezone_set($this->config->timeZone); + + $logLevel = array_search($this->config->logLevel, \Monolog\Logger::getLevels()); + + $this->serviceLogger = new \Monolog\Logger('serviceLogger'); + $this->serviceLogger->pushHandler(new \Monolog\Handler\StreamHandler($this->config->serviceLogFile, $logLevel)); + + $this->auditLogger = new \Monolog\Logger('auditLogger'); + $this->auditLogger->pushHandler(new \Monolog\Handler\StreamHandler($this->config->auditLogFile, $logLevel)); + } + +} diff --git a/classes/MailBodyBuilder.php b/classes/MailBodyBuilder.php deleted file mode 100644 index 6c8baba044c186ba8d93ae8bd1f8dd4402411ff8..0000000000000000000000000000000000000000 --- a/classes/MailBodyBuilder.php +++ /dev/null @@ -1,160 +0,0 @@ -<?php - -namespace RAP; - -/** - * This class is used to build an e-mail message body both in HTML and in its - * equivalent plaintext. - */ -class MailBodyBuilder { - - private $htmlBody; - private $textBody; - private $openedHTMLTags; - private $editable; - - function __construct() { - $this->htmlBody = ""; - $this->textBody = ""; - $this->openedHTMLTags = []; - $this->editable = true; - } - - private function checkEditable() { - if (!$this->editable) { - throw new \Exception("You cannot edit the body after it has been generated"); - } - } - - public function addText($text) { - $this->checkEditable(); - $this->htmlBody .= $text; - $this->textBody .= $text; - return $this; - } - - public function addLineBreak() { - $this->checkEditable(); - $this->htmlBody .= "<br>\n"; - $this->textBody .= "\n"; - return $this; - } - - public function addHr() { - $this->checkEditable(); - $this->htmlBody .= "<hr/>\n"; - $this->textBody .= "\n---------------------\n"; - return $this; - } - - public function addLinkWithDescription($url, $text) { - $this->checkEditable(); - $this->htmlBody .= '<a href="' . $url . '" target="blank_">' . $text . "</a>\n"; - $this->textBody .= $text . " ( " . $url . " ) "; - return $this; - } - - public function addLink($url) { - $this->checkEditable(); - $this->htmlBody .= '<a href="' . $url . '" target="blank_">' . $url . "</a>\n"; - $this->textBody .= $url . " "; - return $this; - } - - public function addEmailAddress($email, $description) { - $this->checkEditable(); - $this->htmlBody .= '<a href="mailto:' . $email . '">' . $description . '</a>'; - $this->textBody .= $description . " (" . $email . ")"; - return $this; - } - - private function openHTMLTag($tag, $equivalentPlainText) { - $this->checkEditable(); - if (in_array($tag, $this->openedHTMLTags)) { - throw new \Exception("You are already inside a " . $tag . " tag!"); - } - $this->openedHTMLTags[] = $tag; - $this->htmlBody .= "<" . $tag . ">"; - $this->textBody .= $equivalentPlainText; - return $this; - } - - private function closeHTMLTag($tag, $equivalentPlainText) { - $this->checkEditable(); - if ($this->openedHTMLTags[count($this->openedHTMLTags) - 1] !== $tag) { - throw new \Exception("You are not inside a " . $tag . " tag!"); - } - array_pop($this->openedHTMLTags); - $this->htmlBody .= "</" . $tag . ">"; - $this->textBody .= $equivalentPlainText; - return $this; - } - - public function startBold() { - return $this->openHTMLTag("strong", "*"); - } - - public function endBold() { - return $this->closeHTMLTag("strong", "*"); - } - - public function startParagraph() { - return $this->openHTMLTag("p", ""); - } - - public function endParagraph() { - return $this->closeHTMLTag("p", "\n"); - } - - public function startList() { - return $this->openHTMLTag("ul", "\n"); - } - - public function startListItem() { - return $this->openHTMLTag("li", " * "); - } - - public function endListItem() { - return $this->closeHTMLTag("li", "\n"); - } - - public function endList() { - return $this->closeHTMLTag("ul", "\n"); - } - - private function checkEnd() { - if (count($this->openedHTMLTags) > 0) { - $unclosedTags = ""; - foreach ($this->openedHTMLTags as $tag) { - $unclosedTags .= $tag . " "; - } - throw new \Exception("You must close all tags before generating email body! Unclosed tags: " . $unclosedTags); - } - } - - private function setCompatibleLineBreaks($value) { - return str_replace("\n", "\n\r", $value); - } - - private function finalizeBodyIfNecessary() { - if ($this->editable) { - $this->checkEnd(); - - $this->htmlBody = $this->setCompatibleLineBreaks($this->htmlBody); - $this->textBody = $this->setCompatibleLineBreaks($this->textBody); - - $this->editable = false; - } - } - - public function getTextPlainBody() { - $this->finalizeBodyIfNecessary(); - return $this->textBody; - } - - public function getHTMLBody() { - $this->finalizeBodyIfNecessary(); - return $this->htmlBody; - } - -} diff --git a/classes/MailSender.php b/classes/MailSender.php deleted file mode 100644 index 5e8c7d434eb48ab9d44bdbbd37a284054d2d2c37..0000000000000000000000000000000000000000 --- a/classes/MailSender.php +++ /dev/null @@ -1,144 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -namespace RAP; - -use \PHPMailer\PHPMailer\PHPMailer; - -/** - * Manage mail sending. - * Currently used only for join email messages. - */ -class MailSender { - - private $serverName; - private $basePath; - private $mbb; - - public function __construct($serverName, $basePath) { - $this->serverName = $serverName; - $this->basePath = $basePath; - $this->mbb = new MailBodyBuilder(); - } - - private function addDescriptionItem($key, $value) { - $this->mbb->startBold() - ->addText($key) - ->endBold() - ->addText(": " . $value) - ->addLineBreak(); - } - - /** - * Send the email for confirming the join request. - * @param \RAP\User $recipientUser user target of the join requests: he/she - * will receive the email containing the confirmation link - * @param \RAP\User $applicantUser user that have requested the join - * @param string $token the join token - */ - public function sendJoinEmail(User $recipientUser, User $applicantUser, $token) { - - global $auditLog; - - $confirmJoinURL = $this->basePath . '/confirm-join?token=' . $token; - - $this->mbb->startParagraph() - ->addText("Dear IA2 user,") - ->addLineBreak() - ->addText("the following user requested to join your accounts on the ") - ->addLinkWithDescription("https://sso.ia2.inaf.it/rap-ia2/", "RAP facility") - ->addText(":") - ->endParagraph(); - - foreach ($applicantUser->identities as $identity) { - - $this->addDescriptionItem("Type", $identity->type); - if ($identity->name !== null) { - $this->addDescriptionItem("Name", $identity->name); - } - if ($identity->surname !== null) { - $this->addDescriptionItem("Surname", $identity->surname); - } - $this->addDescriptionItem("E-mail", $identity->email); - if ($identity->eppn !== null) { - $this->addDescriptionItem("Eppn", $identity->eppn); - } - if ($identity->institution !== null) { - $this->addDescriptionItem("Institution", $identity->institution); - } - - $this->mbb->addLineBreak(); - } - - $this->mbb->startParagraph() - ->addText("If you and this user are ") - ->startBold() - ->addText("the same person") - ->endBold() - ->addText(" click on the following link for joining your accounts: ") - ->addLink($confirmJoinURL) - ->addLineBreak() - ->addText("Otherwise you can ignore this email.") - ->endParagraph() - // - ->startParagraph() - ->startBold() - ->addText("Please don't use this functionality for sharing resources between your coworkers") - ->endBold() - ->addText(", use ") - ->addLinkWithDescription("https://sso.ia2.inaf.it/grouper", "Grouper") - ->addText(" for that.") - ->endParagraph() - // - ->addLineBreak() - ->startBold() - ->addText("*** This is an automatically generated email, please do not reply to this message ***") - ->endBold() - ->addLineBreak() - ->addText("If you need information please contact ") - ->addEmailAddress("ia2@oats.inaf.it", "IA2 Staff"); - - $mail = new PHPMailer(true); // Passing `true` enables exceptions - try { - - $toAddress = $recipientUser->getPrimaryEmail(); - - $mail->isSMTP(); - $mail->Port = 25; - $mail->setFrom("noreply@" . $this->serverName, 'IA2 SSO'); - $mail->addAddress($toAddress); - $mail->CharSet = 'utf-8'; - $mail->Subject = "IA2 RAP: Join request"; - $mail->Body = $this->mbb->getHTMLBody(); - $mail->AltBody = $this->mbb->getTextPlainBody(); - - $auditLog->info("JOIN email. Sending to " . $toAddress); - $mail->send(); - } catch (\Exception $ex) { - error_log($ex->getMessage()); - throw $ex; - } - } - -} diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..12da4751b44284126ad32fe8364d3072984e844d --- /dev/null +++ b/classes/OAuth2RequestHandler.php @@ -0,0 +1,279 @@ +<?php + +namespace RAP; + +class OAuth2RequestHandler { + + private $locator; + + public function __construct(\RAP\Locator $locator) { + $this->locator = $locator; + } + + public function handleAuthorizeRequest($params) { + + if ($params['client_id'] === null) { + throw new BadRequestException("Client id is required"); + } + + if ($params['redirect_uri'] === null) { + throw new BadRequestException("Redirect URI is required"); + } + + $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($params['client_id']); + if ($client === null) { + throw new BadRequestException("Invalid client id: " . $params['client_id']); + } + if ($client->redirectUrl !== $params['redirect_uri']) { + throw new BadRequestException("Invalid client redirect URI: " . $params['redirect_uri']); + } + + $alg = $params['alg']; + if ($alg === null) { + $alg = "RS256"; + } + + $state = $params['state']; + $nonce = $params['nonce']; + + if ($state === null && $nonce === null) { + throw new BadRequestException("State or nonce is required"); + } + + // Storing OAuth2 data in session + $oauth2Data = new OAuth2RequestData(); + $oauth2Data->clientId = $client->client; + $oauth2Data->redirectUrl = $client->redirectUrl; + $oauth2Data->state = $state; + $oauth2Data->nonce = $nonce; + + $scope = $params['scope']; + if ($scope !== null) { + $oauth2Data->scope = explode(' ', $scope); + } + + $session = $this->locator->getSession(); + $session->setOAuth2RequestData($oauth2Data); + } + + public function getRedirectResponseUrl(): string { + + $session = $this->locator->getSession(); + + $code = base64_encode(bin2hex(openssl_random_pseudo_bytes(64))); + + $tokenData = new AccessTokenData(); + // Code is stored in hashed format inside the database, as a basic + // security measure in order to prevent issues in case of data breach. + $tokenData->codeHash = hash('sha256', $code); + $tokenData->userId = $session->getUser()->id; + $tokenData->clientId = $session->getOAuth2RequestData()->clientId; + $tokenData->redirectUri = $session->getOAuth2RequestData()->redirectUrl; + $tokenData->scope = $session->getOAuth2RequestData()->scope; + + $this->locator->getAccessTokenDAO()->createTokenData($tokenData); + + $state = $session->getOAuth2RequestData()->state; + $nonce = $session->getOAuth2RequestData()->nonce; + + if ($state !== null) { + // Authorization code grant flow + $redirectUrl = $session->getOAuth2RequestData()->redirectUrl + . '?code=' . $code . '&scope=profile&state=' . $state; + } else { + // Implicit grant flow + $idToken = $this->locator->getTokenBuilder()->getIdToken($tokenData, function(& $jwt) use($nonce) { + $jwt['nonce'] = $nonce; + }); + $redirectUrl = $session->getOAuth2RequestData()->redirectUrl . "#id_token=" . $idToken; + } + + return $redirectUrl; + } + + public function handleAccessTokenRequest($params): array { + + $this->locator->getClientAuthChecker()->validateClientAuth(); + + if ($params['code'] === null) { + throw new BadRequestException("code id is required"); + } + + if ($params['redirect_uri'] === null) { + throw new BadRequestException("Redirect URI is required"); + } + + // Note: theorically the standard wants also the client_id here, + // however some clients don't send it (e.g. Spring Security library) + // + $codeHash = hash('sha256', $params['code']); + + $tokenData = $this->locator->getAccessTokenDAO()->retrieveTokenDataFromCode($codeHash); + + if ($tokenData === null) { + throw new BadRequestException("No token for given code"); + } + + if ($tokenData->redirectUri !== $params['redirect_uri']) { + throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']); + } + + $response = $this->getAccessTokenResponse($tokenData); + $this->locator->getAccessTokenDAO()->deleteTokenData($codeHash); + return $response; + } + + public function handleRefreshTokenRequest($params): array { + + $this->locator->getClientAuthChecker()->validateClientAuth(); + + if ($params['refresh_token'] === null) { + throw new BadRequestException("refresh_token is required"); + } + + $tokenHash = hash('sha256', $params['refresh_token']); + $refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshTokenData($tokenHash); + + if ($refreshToken === null || $refreshToken->isExpired()) { + throw new UnauthorizedException("Invalid refresh token"); + } + + $scope = $this->getScope($params, $refreshToken); + + // Generating a new access token + $accessTokenData = new AccessTokenData(); + $accessTokenData->clientId = $refreshToken->clientId; + $accessTokenData->userId = $refreshToken->userId; + $accessTokenData->scope = $scope; + + $accessTokenData = $this->locator->getAccessTokenDAO()->createTokenData($accessTokenData); + + return $this->getAccessTokenResponse($accessTokenData); + } + + /** + * We can request a new access token with a scope that is a subset (or the + * same set) of the scope defined for the refresh token. + */ + private function getScope(array $params, RefreshTokenData $refreshToken): ?array { + + $scope = $refreshToken->scope; + + if ($params['scope'] !== null) { + + $newScopeValues = explode(' ', $params['scope']); + + foreach ($newScopeValues as $newScopeValue) { + $found = false; + foreach ($scope as $oldScopeValue) { + if ($oldScopeValue === $newScopeValue) { + $found = true; + break; + } + } + if (!$found) { + throw new BadRequestException("Scope " . $newScopeValue . " was not defined for the given refresh token"); + } + } + + $scope = $newScopeValues; + } + + return $scope; + } + + private function getAccessTokenResponse(AccessTokenData $tokenData) { + + $result = []; + $result['access_token'] = $this->locator->getTokenBuilder()->getAccessToken($tokenData); + $result['token_type'] = 'Bearer'; + $result['expires_in'] = $tokenData->expirationTime - time(); + + $result['refresh_token'] = $this->buildRefreshToken($tokenData); + + if ($tokenData->scope !== null && in_array('openid', $tokenData->scope)) { + $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData); + } + + return $result; + } + + /** + * Token introspection endpoint shouldn't be necessary when using OIDC (since + * tokens are self-contained JWT). This function is kept here for compatibility + * with some libraries (e.g. Spring Security) but it could be removed in the + * future. + */ + public function handleCheckTokenRequest(): array { + + $jwt = $this->locator->getTokenChecker()->validateToken(); + $tokenData = $this->getTokenDataFromJwtObject($jwt); + + $result = []; + $result['exp'] = $tokenData->expirationTime - time(); + $result['user_name'] = $tokenData->userId; + $result['client_id'] = $tokenData->clientId; + $result['access_token'] = $this->copyReceivedAccessToken(); + $result['refresh_token'] = $this->buildRefreshToken($tokenData); + + if (isset($tokenData->scope) && count($tokenData->scope) > 0) { + $result['scope'] = $tokenData->scope; + if (in_array('openid', $tokenData->scope)) { + $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData); + } + } + + return $result; + } + + private function copyReceivedAccessToken(): string { + $headers = apache_request_headers(); + return explode(" ", $headers['Authorization'])[1]; + } + + private function getTokenDataFromJwtObject($jwt): AccessTokenData { + + $tokenData = new AccessTokenData(); + $tokenData->clientId = $this->getClientIdFromAudience($jwt); + $tokenData->userId = $jwt->sub; + $tokenData->creationTime = $jwt->iat; + $tokenData->expirationTime = $jwt->exp; + $tokenData->scope = explode(' ', $jwt->scope); + return $tokenData; + } + + private function getClientIdFromAudience(object $jwt): string { + + if (!(isset($jwt->aud))) { + throw new UnauthorizedException("Missing 'aud' claim in token"); + } + + $audience = $jwt->aud; + if (is_array($audience)) { + if (count($audience) === 0) { + throw new UnauthorizedException("Token has empty audience"); + } + return $audience[0]; + } + return $audience; + } + + private function buildRefreshToken(AccessTokenData $tokenData): string { + $refreshToken = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); + $refreshTokenHash = hash('sha256', $refreshToken); + $this->storeRefreshTokenData($tokenData, $refreshTokenHash); + return $refreshToken; + } + + private function storeRefreshTokenData(AccessTokenData $accessTokenData, string $refreshTokenHash): void { + + $refreshToken = new RefreshTokenData(); + $refreshToken->tokenHash = $refreshTokenHash; + $refreshToken->clientId = $accessTokenData->clientId; + $refreshToken->userId = $accessTokenData->userId; + $refreshToken->scope = $accessTokenData->scope; + + $this->locator->getRefreshTokenDAO()->createRefreshTokenData($refreshToken); + } + +} diff --git a/classes/SessionData.php b/classes/SessionData.php deleted file mode 100644 index 97f0d25b831f27c53a7d53a5f66e6937bb908bf7..0000000000000000000000000000000000000000 --- a/classes/SessionData.php +++ /dev/null @@ -1,132 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -namespace RAP; - -/** - * This class wraps various objects that need to be stored into the session in - * order to provide an object oriented transparent session management. - */ -class SessionData { - - private $dao; - private $callbackURL; - private $callbackTitle; - private $callbackLogo; - private $callbackAuth; - public $user; - public $userSearchResults; - public $x509DataToRegister; - // user which is going to perform the login (we need to store this in the - // session because we need to check the Terms of Use user consensus, so we - // redirect to another page after retrieving user data. - public $userToLogin; - - /** - * @todo: move DAO away from here - */ - public function __construct(DAO $dao) { - $this->dao = $dao; - } - - /** - * Store the data into the $_SESSION PHP variable - */ - public function save() { - $_SESSION['SessionData'] = $this; - } - - /** - * Retrieve the SessionData object from the $_SESSION PHP variable. Create a - * new one if it is necessary. - * @param \RAP\DAO $dao - * @return \RAP\SessionData the SessionData object - */ - public static function get(DAO $dao) { - - if (!isset($_SESSION['SessionData'])) { - $session = new SessionData($dao); - $session->save(); - } - return $_SESSION['SessionData']; - } - - public function setCallbackURL(CallbackHandler $callbackHandler, $callbackURL) { - $this->callbackURL = $callbackHandler->filterCallbackURL($callbackURL); - $this->callbackTitle = $callbackHandler->getCallbackTitle($callbackURL); - $this->callbackLogo = $callbackHandler->getCallbackLogo($callbackURL); - $this->callbackAuth = $callbackHandler->getCallbackAuth($callbackURL); - $this->save(); - } - - public function getCallbackURL() { - return $this->callbackURL; - } - - public function getCallbackTitle() { - return $this->callbackTitle; - } - - public function getCallbackLogo() { - return $this->callbackLogo; - } - - public function getCallbackAuth() { - return $this->callbackAuth; - } - - /** - * Perform a user search and store the results inside the session. This is - * used for achieving the user selection using the dropdown menu in the join - * request modal. - * @param string $searchText - */ - public function searchUser($searchText) { - $users = $this->dao->searchUser($searchText); - - $this->userSearchResults = []; - foreach ($users as $user) { - // this search shouldn't contains the user itself - if ($user->id !== $this->user->id) { - $searchResult = UserSearchResult::buildFromUser($user); - array_push($this->userSearchResults, $searchResult); - } - } - - $this->save(); - } - - /** - * Update the user data model stored into the session after the primary - * identity has changed, in order to avoid reading again the user data from - * the database. - * @param int $identityId - */ - public function updatePrimaryIdentity($identityId) { - foreach ($this->user->identities as $identity) { - $identity->primary = ($identity->id === $identityId); - } - } - -} diff --git a/classes/TokenBuilder.php b/classes/TokenBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..abdb369f1610fe423232f3d0a217689bf334ab98 --- /dev/null +++ b/classes/TokenBuilder.php @@ -0,0 +1,123 @@ +<?php + +namespace RAP; + +use \Firebase\JWT\JWT; + +class TokenBuilder { + + private $locator; + + public function __construct(Locator $locator) { + $this->locator = $locator; + } + + public function getIdToken(AccessTokenData $tokenData, \Closure $jwtCustomizer = null): string { + + $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); + + $payload = $this->createIdTokenPayloadArray($tokenData, $jwtCustomizer); + + return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); + } + + private function createIdTokenPayloadArray(AccessTokenData $tokenData, \Closure $jwtCustomizer = null) { + + $user = $this->locator->getUserDAO()->findUserById($tokenData->userId); + + $payloadArr = array( + 'iss' => $this->locator->config->jwtIssuer, + 'sub' => strval($user->id), + 'iat' => intval($tokenData->creationTime), + 'exp' => intval($tokenData->expirationTime), + 'name' => $user->getCompleteName(), + 'aud' => $tokenData->clientId + ); + + if (in_array("email", $tokenData->scope)) { + $payloadArr['email'] = $user->getPrimaryEmail(); + } + if (in_array("profile", $tokenData->scope)) { + $payloadArr['given_name'] = $user->getName(); + $payloadArr['family_name'] = $user->getSurname(); + if ($user->getInstitution() !== null) { + $payloadArr['org'] = $user->getInstitution(); + } + } + + if ($jwtCustomizer !== null) { + // Add additional custom claims + $jwtCustomizer($payloadArr); + } + + return $payloadArr; + } + + public function getAccessToken(AccessTokenData $tokenData, \Closure $jwtCustomizer = null) { + + $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); + + $user = $this->locator->getUserDAO()->findUserById($tokenData->userId); + + $payload = array( + 'iss' => $this->locator->config->jwtIssuer, + 'sub' => strval($user->id), + 'iat' => intval($tokenData->creationTime), + 'exp' => intval($tokenData->expirationTime), + 'aud' => $this->getAudience($tokenData), + 'scope' => implode(' ', $tokenData->scope) + ); + if ($jwtCustomizer !== null) { + // Add additional custom claims + $jwtCustomizer($payload); + } + + return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); + } + + private function getAudience(AccessTokenData $tokenData) { + + $client = $this->locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($tokenData->clientId); + + $audiences = [$tokenData->clientId]; + + foreach ($tokenData->scope as $scope) { + if (array_key_exists($scope, $client->scopeAudienceMap)) { + $audience = $client->scopeAudienceMap[$scope]; + if (!in_array($audience, $audiences)) { + array_push($audiences, $audience); + } + } + } + + if (count($audiences) === 1) { + // according to RFC 7519 audience can be a single value or an array + return $audiences[0]; + } + return $audiences; + } + + /** + * @param int $lifespan in hours + * @param string $audience target service + */ + public function generateNewToken(int $lifespan, string $audience) { + $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); + + $user = $this->locator->getSession()->getUser(); + + $iat = time(); + $exp = $iat + $lifespan * 3600; + + $payload = array( + 'iss' => $this->locator->config->jwtIssuer, + 'sub' => strval($user->id), + 'iat' => $iat, + 'exp' => $exp, + 'aud' => $audience + ); + + return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); + } + +} diff --git a/classes/TokenChecker.php b/classes/TokenChecker.php new file mode 100644 index 0000000000000000000000000000000000000000..370bc8526f6470d145f93e8798ce79d7aed4a74c --- /dev/null +++ b/classes/TokenChecker.php @@ -0,0 +1,73 @@ +<?php + +namespace RAP; + +use \Firebase\JWT\JWT; + +class TokenChecker { + + private $locator; + + public function __construct(Locator $locator) { + $this->locator = $locator; + } + + public function validateToken(): object { + $headers = apache_request_headers(); + + if (!isset($headers['Authorization'])) { + throw new BadRequestException("Missing Authorization header"); + } + + $authorizationHeader = explode(" ", $headers['Authorization']); + if ($authorizationHeader[0] === "Bearer") { + $token = $authorizationHeader[1]; + } else { + throw new BadRequestException("Invalid token type"); + } + + return $this->attemptJWTTokenValidation($token); + } + + private function attemptJWTTokenValidation($jwt): object { + + $jwtParts = explode('.', $jwt); + if (count($jwtParts) === 0) { + throw new UnauthorizedException("Invalid token"); + } + + $header = JWT::jsonDecode(JWT::urlsafeB64Decode($jwtParts[0])); + if (!isset($header->kid)) { + throw new UnauthorizedException("Invalid token: missing kid in header"); + } + + $keyPair = $this->locator->getJWKSDAO()->getRSAKeyPairById($header->kid); + if ($keyPair === null) { + throw new UnauthorizedException("Invalid kid: no key found"); + } + + try { + return JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]); + } catch (\Firebase\JWT\ExpiredException $ex) { + throw new UnauthorizedException("Access token is expired"); + } + } + + public function checkScope(object $tokenData, string $desiredScope): void { + + if (!(isset($tokenData->scope))) { + throw new UnauthorizedException("Missing 'scope' claim in access token"); + } + + $scopes = explode(' ', $tokenData->scope); + + foreach ($scopes as $scope) { + if ($scope === $desiredScope) { + return; + } + } + + throw new UnauthorizedException("Scope '$desiredScope' is required for performing this action"); + } + +} diff --git a/classes/User.php b/classes/User.php deleted file mode 100644 index 21967ff717e1ab17a1dccb49f73912a5c3072d5b..0000000000000000000000000000000000000000 --- a/classes/User.php +++ /dev/null @@ -1,55 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -namespace RAP; - -/** - * Data model for the user. An user is a set of identities. - */ -class User { - - // User ID - public $id; - // List of identities - public $identities; - - public function __construct() { - $this->identities = []; - } - - public function addIdentity(Identity $identity) { - array_push($this->identities, $identity); - } - - public function getPrimaryEmail() { - foreach ($this->identities as $identity) { - if ($identity->primary) { - return $identity->email; - } - } - // A primary identity MUST be defined - throw new \Exception("No primary identity defined for user " . $this->id); - } - -} diff --git a/classes/UserHandler.php b/classes/UserHandler.php index 4614a8f17b5004b7552c16e2a60eda7d56f8a76b..f744588c663e72c52140bd92468c62f2036ff268 100644 --- a/classes/UserHandler.php +++ b/classes/UserHandler.php @@ -29,12 +29,12 @@ namespace RAP; */ class UserHandler { - private $dao; - private $grouperConfig; + private $locator; + private $userDAO; - public function __construct(DAO $dao, $grouperConfig) { - $this->dao = $dao; - $this->grouperConfig = $grouperConfig; + public function __construct(Locator $locator) { + $this->locator = $locator; + $this->userDAO = $locator->getUserDAO(); } /** @@ -49,59 +49,41 @@ class UserHandler { // If new user if ($user->id === null) { $primarySpecified = false; - $user->id = $this->dao->createUser(); + $user->id = $this->userDAO->createUser(); } foreach ($user->identities as $identity) { if ($identity->id === null) { - $identity->id = $this->dao->insertIdentity($identity, $user->id); + $identity->id = $this->userDAO->insertIdentity($identity, $user->id); if (!$primarySpecified) { - $this->dao->setPrimaryIdentity($user->id, $identity->id); + $this->userDAO->setPrimaryIdentity($user->id, $identity->id); $identity->primary = true; } } } } - public function findUserByIdentity($type, $identifier) { + public function joinUsers(User $user1, User $user2): User { - return $this->dao->findUserByIdentity($type, $identifier); - } - - /** - * Build an URL for the web service endpoint that needs to be called in order - * to move groups from one user to the other during a join operation. - * @return string grouper URL for the PrepareToJoinServlet - */ - private function getJoinURL() { - $joinURL = $this->grouperConfig['wsURL']; - - if (substr($joinURL, -1) !== '/') { - $joinURL .= '/'; - } - $joinURL .= 'ia2join'; - - return $joinURL; - } - - public function joinUsers($userId1, $userId2) { + $userId1 = $user1->id; + $userId2 = $user2->id; // Call Grouper for moving groups and privileges from one user to the other - if ($this->grouperConfig !== null) { + if (isset($this->locator->config->gms)) { //create cURL connection - $conn = curl_init($this->getJoinURL()); + $conn = curl_init($this->locator->config->gms->joinEndpoint); //set options curl_setopt($conn, CURLOPT_CONNECTTIMEOUT, 30); curl_setopt($conn, CURLOPT_RETURNTRANSFER, true); curl_setopt($conn, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($conn, CURLOPT_FOLLOWLOCATION, 1); - curl_setopt($conn, CURLOPT_USERPWD, $this->grouperConfig['user'] . ":" . $this->grouperConfig['password']); + curl_setopt($conn, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' + . $this->getJoinAccessToken($userId1, $userId2)]); //set data to be posted curl_setopt($conn, CURLOPT_POST, 1); - curl_setopt($conn, CURLOPT_POSTFIELDS, "subject1Id=RAP:$userId1&subject2Id=RAP:$userId2"); //perform the request $response = curl_exec($conn); @@ -112,13 +94,38 @@ class UserHandler { } else { //show information regarding the error curl_close($conn); + error_log($response); http_response_code(500); - die('Error: Grouper response code: ' . $info['http_code']); + die('Error: GMS response code: ' . $info['http_code'] . "\n"); } } // Call DAO for performing join operation into the RAP database. - $this->dao->joinUsers($userId1, $userId2); + $this->userDAO->joinUsers($userId1, $userId2); + + foreach ($user2->identities as $identity) { + $identity->primary = false; + $user1->addIdentity($identity); + } + + // merged user + return $user1; + } + + private function getJoinAccessToken(int $userId1, int $userId2): string { + + $gmsId = $this->locator->config->gms->id; + + $accessToken = new AccessTokenData(); + $accessToken->clientId = $gmsId; + $accessToken->userId = $userId1; + // shorter expiration + $accessToken->expirationTime = $accessToken->creationTime + 100; + $accessToken->scope = ['openid']; + + return $this->locator->getTokenBuilder()->getAccessToken($accessToken, function(& $jwt) use($userId2) { + $jwt['alt_sub'] = strval($userId2); + }); } } diff --git a/classes/UserSearchResult.php b/classes/UserSearchResult.php deleted file mode 100644 index c1d49d90c8284a12229685fddb6ba6e704b10f86..0000000000000000000000000000000000000000 --- a/classes/UserSearchResult.php +++ /dev/null @@ -1,88 +0,0 @@ -<?php - -/* ---------------------------------------------------------------------------- - * INAF - National Institute for Astrophysics - * IRA - Radioastronomical Institute - Bologna - * OATS - Astronomical Observatory - Trieste - * ---------------------------------------------------------------------------- - * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License Version 3 as published by the - * Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more - * details. - * - * You should have received a copy of the GNU General Public License along with - * this program; if not, write to the Free Software Foundation, Inc., 51 - * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -namespace RAP; - -/** - * Data model representing an item of the result of an user search. This is used - * in order to display the dropdown menu for user selection in the join modal - * dialog avoiding exposing the user identifiers in the AJAX calls. - * This data is stored into a list inside the SessionData object. The user will - * select one of the item by index and not by the user ID. - */ -class UserSearchResult { - - // The user object is wrapped by this class and hidden to AJAX. - private $user; - // Only this text is returned to the AJAX call. - // See gui-backend.php (/user?search endpoint) - public $userDisplayText; - - public static function buildFromUser(User $user) { - $usr = new UserSearchResult(); - $usr->user = $user; - - $nameAndSurname = null; - $email = null; - $identityTypes = []; - foreach ($user->identities as $identity) { - array_push($identityTypes, $identity->getUIType()); - if ($nameAndSurname === null && $identity->name !== null && $identity->surname !== null) { - $nameAndSurname = $identity->name . ' ' . $identity->surname; - } - if ($email === null) { - $email = $identity->email; - } - } - - // Building display text string - $displayText = ""; - - if ($nameAndSurname !== null) { - $displayText .= $nameAndSurname; - } else { - $displayText .= $email; - } - - $displayText .= ' ('; - $firstIdentity = true; - foreach ($identityTypes as $type) { - if (!$firstIdentity) { - $displayText .= '+'; - } - $displayText .= $type; - $firstIdentity = false; - } - $displayText .= ')'; - - $usr->userDisplayText = $displayText; - - return $usr; - } - - public function getUser() { - return $this->user; - } - -} diff --git a/classes/X509Data.php b/classes/X509Data.php index ddfb4028bd44088b4c043b8f4b8127bc3cfd2148..3b8a931378138c4863bca14fa28e617ea3257a76 100644 --- a/classes/X509Data.php +++ b/classes/X509Data.php @@ -203,9 +203,7 @@ class X509Data { return $parsedData; } - public function toUser() { - - $user = new User(); + public function toIdentity() { $identity = new Identity(Identity::X509); $identity->email = $this->email; @@ -214,9 +212,7 @@ class X509Data { $identity->typedId = $this->serialNumber; $identity->institution = $this->institution; - $user->addIdentity($identity); - - return $user; + return $identity; } } diff --git a/classes/datalayer/AccessTokenDAO.php b/classes/datalayer/AccessTokenDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..a7d6090c50aa72d1f008ba3aa650fabddea73184 --- /dev/null +++ b/classes/datalayer/AccessTokenDAO.php @@ -0,0 +1,17 @@ +<?php + +namespace RAP; + +interface AccessTokenDAO { + + /** + * Store a new login token into the database. + * @param type $token login token + * @param type $userId + */ + function createTokenData(AccessTokenData $tokenData): AccessTokenData; + + function retrieveTokenDataFromCode(string $codeHash): ?AccessTokenData; + + function deleteTokenData(string $codeHash): void; +} diff --git a/classes/datalayer/JWKSDAO.php b/classes/datalayer/JWKSDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..9cd83f49900a9cfccd8cbadc77caa4116da2e0f2 --- /dev/null +++ b/classes/datalayer/JWKSDAO.php @@ -0,0 +1,14 @@ +<?php + +namespace RAP; + +interface JWKSDAO { + + public function getRSAKeyPairs(): array; + + public function getRSAKeyPairById(string $id): ?RSAKeyPair; + + public function insertRSAKeyPair(RSAKeyPair $keyPair): RSAKeyPair; + + public function getNewestKeyPair(): ?RSAKeyPair; +} diff --git a/classes/datalayer/OAuth2ClientDAO.php b/classes/datalayer/OAuth2ClientDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..ffa4520b21fb64a213166d854de9f104776e4ba1 --- /dev/null +++ b/classes/datalayer/OAuth2ClientDAO.php @@ -0,0 +1,23 @@ +<?php + +namespace RAP; + +/** + * CRUD methods for OAuth2Clients (used by admin interface). + */ +interface OAuth2ClientDAO { + + function getOAuth2Clients(): array; + + function createOAuth2Client(OAuth2Client $client): OAuth2Client; + + function updateOAuth2Client(OAuth2Client $client): OAuth2Client; + + function deleteOAuth2Client($clientId); + + /** + * Retrieve the client from the configured client id (the one associated to + * the secret, not the database id). + */ + function getOAuth2ClientByClientId($clientId): ?OAuth2Client; +} diff --git a/classes/datalayer/RefreshTokenDAO.php b/classes/datalayer/RefreshTokenDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..8124e96618bf56bb041006813d99fba55dd4bf5f --- /dev/null +++ b/classes/datalayer/RefreshTokenDAO.php @@ -0,0 +1,12 @@ +<?php + +namespace RAP; + +interface RefreshTokenDAO { + + function createRefreshTokenData(RefreshTokenData $refreshToken): RefreshTokenData; + + function getRefreshTokenData(string $tokenHash): ?RefreshTokenData; + + function deleteRefreshTokenData(string $tokenHash): void; +} diff --git a/classes/DAO.php b/classes/datalayer/UserDAO.php similarity index 56% rename from classes/DAO.php rename to classes/datalayer/UserDAO.php index c5d5039b41e0661053affd93f8f5f9e64ea9edd3..a91d6275c533887833f10825c1ecfc1c607f131e 100644 --- a/classes/DAO.php +++ b/classes/datalayer/UserDAO.php @@ -24,38 +24,7 @@ namespace RAP; -/** - * Data Access Object interface for accessing the RAP database. - * Current implementations: RAP\MySQLDAO - */ -interface DAO { - - /** - * @return type PDO object for accessing the database - */ - function getDBHandler(); - - /** - * Store a new login token into the database. - * @param type $token login token - * @param type $userId - */ - function createLoginToken($token, $userId); - - /** - * Retrieve the user ID from the login token. - * @param type $token - * @return type user ID - */ - function findLoginToken($token); - - /** - * Delete a login token from the database. This happens when the caller - * application has received the token and used it for retrieving user - * information from the token using the RAP REST web service. - * @param type $token login token - */ - function deleteLoginToken($token); +interface UserDAO { /** * Create a new identity. @@ -73,7 +42,7 @@ interface DAO { /** * @return RAP\User an user object, null if nothing was found. */ - function findUserById($userId); + function findUserById(string $userId): ?User; function setPrimaryIdentity($userId, $identityId); @@ -93,22 +62,11 @@ interface DAO { function searchUser($searchText); /** - * Store into the database information about a new join request. - * @param type $token join token - * @param type $applicantUserId the user asking for the join - * @param type $targetUserId the user target of the join + * Retrieve a list of all users having given identifiers. + * @param array $identifiers + * @return array */ - function createJoinRequest($token, $applicantUserId, $targetUserId); - - /** - * Retrieve join request information. - * @param type $token join token - * @return an array of 2 elements having the applicant user id at the first - * position and the target user id at the second position; null if nothing - * was found. - * @throws Exception if multiple requests has been found for the same token. - */ - function findJoinRequest($token); + function getUsers(array $identifiers): array; /** * Perform a join request. @@ -118,10 +76,5 @@ interface DAO { */ function joinUsers($userId1, $userId2); - /** - * When a join action is performed the join request data (join token and user - * identifiers) needs to be removed from the database. - * @param type $token join token - */ - function deleteJoinRequest($token); + function isAdmin($userId): bool; } diff --git a/classes/datalayer/mysql/BaseMySQLDAO.php b/classes/datalayer/mysql/BaseMySQLDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..7798f38be74c4eddc0d24c7ea1075a6a16fe59bd --- /dev/null +++ b/classes/datalayer/mysql/BaseMySQLDAO.php @@ -0,0 +1,27 @@ +<?php + +namespace RAP; + +use PDO; + +abstract class BaseMySQLDAO { + + private $locator; + + public function __construct(Locator $locator) { + $this->locator = $locator; + } + + /** + * @return type PDO object for accessing the database + */ + public function getDBHandler(): PDO { + $config = $this->locator->config->databaseConfig; + $connectionString = "mysql:host=" . $config->hostname . ";port=" . $config->port . ";dbname=" . $config->dbname; + $dbh = new PDO($connectionString, $config->username, $config->password); + // For transaction errors (see https://stackoverflow.com/a/9659366/771431) + $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + return $dbh; + } + +} diff --git a/classes/datalayer/mysql/MySQLAccessTokenDAO.php b/classes/datalayer/mysql/MySQLAccessTokenDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..00b5954cb3602ed47bda60615f55160c58b7c437 --- /dev/null +++ b/classes/datalayer/mysql/MySQLAccessTokenDAO.php @@ -0,0 +1,89 @@ +<?php + +namespace RAP; + +class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { + + public function __construct(Locator $locator) { + parent::__construct($locator); + } + + public function createTokenData(AccessTokenData $tokenData): AccessTokenData { + + $dbh = $this->getDBHandler(); + $stmt = $dbh->prepare("INSERT INTO access_token (code_hash, user_id, redirect_uri, client_id, scope, creation_time, expiration_time)" + . " VALUES(:code_hash, :user_id, :redirect_uri, :client_id, :scope, :creation_time, :expiration_time)"); + + $scope = null; + if ($tokenData->scope !== null) { + $scope = join(' ', $tokenData->scope); + } + + $params = array( + ':code_hash' => $tokenData->codeHash, + ':user_id' => $tokenData->userId, + ':redirect_uri' => $tokenData->redirectUri, + ':client_id' => $tokenData->clientId, + ':scope' => $scope, + ':creation_time' => $tokenData->creationTime, + ':expiration_time' => $tokenData->expirationTime + ); + + if ($stmt->execute($params)) { + return $tokenData; + } else { + error_log($stmt->errorInfo()[2]); + throw new \Exception("SQL error while storing user token"); + } + } + + public function retrieveTokenDataFromCode(string $codeHash): ?AccessTokenData { + + $dbh = $this->getDBHandler(); + + // Access token can be retrieved from code in 1 minute from the creation + $stmt = $dbh->prepare("SELECT user_id, redirect_uri, client_id, creation_time, expiration_time, scope " + . " FROM access_token WHERE code_hash = :code_hash AND UNIX_TIMESTAMP() < (creation_time + 60)"); + $stmt->bindParam(':code_hash', $codeHash); + + $stmt->execute(); + + $row = $stmt->fetch(); + if (!$row) { + return null; + } + + return $this->getTokenDataFromRow($row); + } + + private function getTokenDataFromRow(array $row): AccessTokenData { + + $token = new AccessTokenData(); + $token->userId = $row['user_id']; + $token->redirectUri = $row['redirect_uri']; + $token->clientId = $row['client_id']; + $token->creationTime = $row['creation_time']; + $token->expirationTime = $row['expiration_time']; + + $scope = null; + if (isset($row['scope'])) { + $scope = $row['scope']; + } + if ($scope !== null && $scope !== '') { + $token->scope = explode(' ', $scope); + } + + return $token; + } + + function deleteTokenData(string $codeHash): void { + + $dbh = $this->getDBHandler(); + + $stmt = $dbh->prepare("DELETE FROM access_token WHERE code_hash = :code_hash"); + $stmt->bindParam(':code_hash', $codeHash); + + $stmt->execute(); + } + +} diff --git a/classes/datalayer/mysql/MySQLJWKSDAO.php b/classes/datalayer/mysql/MySQLJWKSDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..8e7a83ad188340b257950396055f12ceee49ec17 --- /dev/null +++ b/classes/datalayer/mysql/MySQLJWKSDAO.php @@ -0,0 +1,88 @@ +<?php + +namespace RAP; + +class MySQLJWKSDAO extends BaseMySQLDAO implements JWKSDAO { + + public function __construct($config) { + parent::__construct($config); + } + + public function insertRSAKeyPair(RSAKeyPair $keyPair): RSAKeyPair { + + $dbh = $this->getDBHandler(); + + $query = "INSERT INTO rsa_keypairs(id, private_key, public_key, alg) VALUES (:id, :private_key, :public_key, :alg)"; + + $stmt = $dbh->prepare($query); + $stmt->bindParam(':id', $keyPair->keyId); + $stmt->bindParam(':private_key', $keyPair->privateKey); + $stmt->bindParam(':public_key', $keyPair->publicKey); + $stmt->bindParam(':alg', $keyPair->alg); + + $stmt->execute(); + + return $keyPair; + } + + public function getRSAKeyPairs(): array { + + $dbh = $this->getDBHandler(); + + $query = "SELECT id, private_key, public_key, alg, creation_time FROM rsa_keypairs"; + + $stmt = $dbh->prepare($query); + $stmt->execute(); + + $keyPairs = []; + foreach ($stmt->fetchAll() as $row) { + $keyPair = $this->getRSAKeyPairFromResultRow($row); + array_push($keyPairs, $keyPair); + } + + return $keyPairs; + } + + public function getRSAKeyPairById(string $id): ?RSAKeyPair { + + $dbh = $this->getDBHandler(); + + $query = "SELECT id, private_key, public_key, alg, creation_time FROM rsa_keypairs WHERE id = :id"; + + $stmt = $dbh->prepare($query); + $stmt->bindParam(':id', $id); + $stmt->execute(); + + foreach ($stmt->fetchAll() as $row) { + return $this->getRSAKeyPairFromResultRow($row); + } + + return null; + } + + public function getNewestKeyPair(): ?RSAKeyPair { + $dbh = $this->getDBHandler(); + + $query = "SELECT id, private_key, public_key, alg, creation_time FROM rsa_keypairs ORDER BY creation_time DESC LIMIT 1"; + + $stmt = $dbh->prepare($query); + $stmt->execute(); + + foreach ($stmt->fetchAll() as $row) { + return $this->getRSAKeyPairFromResultRow($row); + } + + return null; + } + + private function getRSAKeyPairFromResultRow(array $row): RSAKeyPair { + $keyPair = new RSAKeyPair(); + $keyPair->keyId = $row['id']; + $keyPair->privateKey = $row['private_key']; + $keyPair->publicKey = $row['public_key']; + $keyPair->alg = $row['alg']; + $keyPair->creationTime = $row['creation_time']; + return $keyPair; + } + +} diff --git a/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..7890e95c8ee1b5b48fa4814a1f03a81d81c7070a --- /dev/null +++ b/classes/datalayer/mysql/MySQLOAuth2ClientDAO.php @@ -0,0 +1,249 @@ +<?php + +namespace RAP; + +class MySQLOAuth2ClientDAO extends BaseMySQLDAO implements OAuth2ClientDAO { + + public function __construct($config) { + parent::__construct($config); + } + + public function getOAuth2Clients(): array { + + $dbh = $this->getDBHandler(); + + $clientsMap = $this->getClientsMap($dbh); + $this->loadAuthenticationMethods($dbh, $clientsMap); + $this->loadScopeAudienceMapping($dbh, $clientsMap); + + $clients = []; + foreach ($clientsMap as $id => $client) { + array_push($clients, $client); + } + + return $clients; + } + + private function getClientsMap(\PDO $dbh): array { + + // Load clients info + $queryClient = "SELECT id, title, icon, client, secret, redirect_url, scope, home_page, show_in_home FROM oauth2_client"; + $stmtClients = $dbh->prepare($queryClient); + $stmtClients->execute(); + + $clientsMap = []; + + foreach ($stmtClients->fetchAll() as $row) { + $client = new OAuth2Client(); + $client->id = $row['id']; + $client->title = $row['title']; + $client->icon = $row['icon']; + $client->client = $row['client']; + $client->secret = $row['secret']; + $client->redirectUrl = $row['redirect_url']; + $client->scope = $row['scope']; + $client->homePage = $row['home_page']; + $client->showInHome = boolval($row['show_in_home']); + $clientsMap[$client->id] = $client; + } + + return $clientsMap; + } + + private function loadAuthenticationMethods(\PDO $dbh, array $clientsMap): void { + + $queryAuthNMethods = "SELECT client_id, auth_method FROM oauth2_client_auth_methods"; + + $stmtAuthNMethods = $dbh->prepare($queryAuthNMethods); + $stmtAuthNMethods->execute(); + + foreach ($stmtAuthNMethods->fetchAll() as $row) { + $id = $row['client_id']; + array_push($clientsMap[$id]->authMethods, $row['auth_method']); + } + } + + private function loadScopeAudienceMapping(\PDO $dbh, array $clientsMap): void { + + $query = "SELECT client_id, scope, audience FROM oauth2_client_scope_audience_mapping"; + + $stmt = $dbh->prepare($query); + + foreach ($stmt->fetchAll() as $row) { + $id = $row['client_id']; + + if (array_key_exists($id, $clientsMap)) { + $client = $clientsMap[$id]; + $client->scopeAudienceMap[$row['scope']] = $row['audience']; + } + + array_push($clientsMap[$id]->authMethods, $row['auth_method']); + } + } + + function createOAuth2Client(OAuth2Client $client): OAuth2Client { + $dbh = $this->getDBHandler(); + + try { + $dbh->beginTransaction(); + + $stmt = $dbh->prepare("INSERT INTO `oauth2_client`(`title`, `icon`, `client`, `secret`, `redirect_url`, `scope`, home_page, show_in_home)" + . " VALUES(:title, :icon, :client, :secret, :redirect_url, :scope, :home_page, :show_in_home)"); + + $stmt->bindParam(':title', $client->title); + $stmt->bindParam(':icon', $client->icon); + $stmt->bindParam(':client', $client->client); + $stmt->bindParam(':secret', $client->secret); + $stmt->bindParam(':redirect_url', $client->redirectUrl); + $stmt->bindParam(':scope', $client->scope); + $stmt->bindParam(':home_page', $client->homePage); + $stmt->bindParam(':show_in_home', $client->showInHome, \PDO::PARAM_INT); + + $stmt->execute(); + + $client->id = $dbh->lastInsertId(); + + foreach ($client->authMethods as $method) { + $stmt = $dbh->prepare("INSERT INTO `oauth2_client_auth_methods`(`client_id`, `auth_method`)" + . " VALUES(:client_id, :auth_method)"); + + $stmt->bindParam(':client_id', $client->id); + $stmt->bindParam(':auth_method', $method); + + $stmt->execute(); + } + + $dbh->commit(); + } catch (Exception $ex) { + $dbh->rollBack(); + throw $ex; + } + + return $client; + } + + function updateOAuth2Client(OAuth2Client $client): OAuth2Client { + $dbh = $this->getDBHandler(); + + try { + $dbh->beginTransaction(); + + $stmt = $dbh->prepare("UPDATE `oauth2_client` SET `title` = :title, `icon` = :icon, " + . " `client` = :client, `secret` = :secret, `redirect_url` = :redirect_url, `scope` = :scope, " + . " `home_page` = :home_page, `show_in_home` = :show_in_home" + . " WHERE id = :id"); + + $stmt->bindParam(':title', $client->title); + $stmt->bindParam(':icon', $client->icon); + $stmt->bindParam(':client', $client->client); + $stmt->bindParam(':secret', $client->secret); + $stmt->bindParam(':redirect_url', $client->redirectUrl); + $stmt->bindParam(':scope', $client->scope); + $stmt->bindParam(':home_page', $client->homePage); + $stmt->bindParam(':show_in_home', $client->showInHome, \PDO::PARAM_INT); + $stmt->bindParam(':id', $client->id); + + $stmt->execute(); + + // Delete old authentication methods + $stmt = $dbh->prepare("DELETE FROM oauth2_client_auth_methods WHERE client_id = :id"); + $stmt->bindParam(':id', $client->id); + + $stmt->execute(); + + // Re-add authentication methods + foreach ($client->authMethods as $method) { + $stmt = $dbh->prepare("INSERT INTO `oauth2_client_auth_methods`(`client_id`, `auth_method`)" + . " VALUES(:client_id, :auth_method)"); + + $stmt->bindParam(':client_id', $client->id); + $stmt->bindParam(':auth_method', $method); + + $stmt->execute(); + } + + $dbh->commit(); + } catch (Exception $ex) { + $dbh->rollBack(); + throw $ex; + } + + return $client; + } + + function deleteOAuth2Client($clientId) { + $dbh = $this->getDBHandler(); + try { + $dbh->beginTransaction(); + + $stmt = $dbh->prepare("DELETE FROM `oauth2_client_auth_methods` WHERE client_id = :id"); + $stmt->bindParam(':id', $clientId); + $stmt->execute(); + + $stmt = $dbh->prepare("DELETE FROM `oauth2_client` WHERE id = :id"); + $stmt->bindParam(':id', $clientId); + $stmt->execute(); + + $dbh->commit(); + } catch (Exception $ex) { + $dbh->rollBack(); + throw $ex; + } + } + + function getOAuth2ClientByClientId($clientId): ?OAuth2Client { + $dbh = $this->getDBHandler(); + + // Load clients info + $queryClient = "SELECT id, title, icon, client, secret, redirect_url, scope, home_page, show_in_home FROM oauth2_client WHERE client = :client"; + $stmtClient = $dbh->prepare($queryClient); + $stmtClient->bindParam(':client', $clientId); + $stmtClient->execute(); + + $result = $stmtClient->fetchAll(); + + if (count($result) === 0) { + return null; + } + if (count($result) > 1) { + throw new \Exception("Found multiple clients associated to the same client id!"); + } + + $row = $result[0]; + + $client = new OAuth2Client(); + $client->id = $row['id']; + $client->title = $row['title']; + $client->icon = $row['icon']; + $client->client = $row['client']; + $client->secret = $row['secret']; + $client->redirectUrl = $row['redirect_url']; + $client->scope = $row['scope']; + $client->homePage = $row['home_page']; + $client->showInHome = $row['show_in_home']; + + // Load authentication methods info + $queryAuthNMethods = "SELECT auth_method FROM oauth2_client_auth_methods WHERE client_id = :id"; + + $stmtAuthNMethods = $dbh->prepare($queryAuthNMethods); + $stmtAuthNMethods->bindParam(':id', $client->id); + $stmtAuthNMethods->execute(); + + foreach ($stmtAuthNMethods->fetchAll() as $row) { + array_push($client->authMethods, $row['auth_method']); + } + + // Load scope-audience mapping + $queryAudienceMapping = "SELECT scope, audience FROM oauth2_client_scope_audience_mapping WHERE client_id = :id"; + $stmtAudienceMapping = $dbh->prepare($queryAudienceMapping); + $stmtAudienceMapping->bindParam(':id', $client->id); + $stmtAudienceMapping->execute(); + + foreach ($stmtAudienceMapping->fetchAll() as $row) { + $client->scopeAudienceMap[$row['scope']] = $row['audience']; + } + + return $client; + } + +} diff --git a/classes/datalayer/mysql/MySQLRefreshTokenDAO.php b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php new file mode 100644 index 0000000000000000000000000000000000000000..28b5c2804a9b7cbdf4d092ed38d032774db5af39 --- /dev/null +++ b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php @@ -0,0 +1,82 @@ +<?php + +namespace RAP; + +class MySQLRefreshTokenDAO extends BaseMySQLDAO implements RefreshTokenDAO { + + public function __construct(Locator $locator) { + parent::__construct($locator); + } + + function createRefreshTokenData(RefreshTokenData $refreshTokenData): RefreshTokenData { + + $dbh = $this->getDBHandler(); + $stmt = $dbh->prepare("INSERT INTO refresh_token (token_hash, user_id, client_id, scope, creation_time, expiration_time)" + . " VALUES(:token_hash, :user_id, :client_id, :scope, :creation_time, :expiration_time)"); + + $scope = null; + if ($refreshTokenData->scope !== null) { + $scope = join(' ', $refreshTokenData->scope); + } + + $params = array( + ':token_hash' => $refreshTokenData->tokenHash, + ':user_id' => $refreshTokenData->userId, + ':client_id' => $refreshTokenData->clientId, + ':scope' => $scope, + ':creation_time' => $refreshTokenData->creationTime, + ':expiration_time' => $refreshTokenData->expirationTime + ); + + if ($stmt->execute($params)) { + return $refreshTokenData; + } else { + error_log($stmt->errorInfo()[2]); + throw new \Exception("SQL error while storing user token"); + } + } + + function getRefreshTokenData(string $tokenHash): ?RefreshTokenData { + + $dbh = $this->getDBHandler(); + + $stmt = $dbh->prepare("SELECT user_id, client_id, creation_time, expiration_time, scope " + . " FROM refresh_token WHERE token_hash = :token_hash"); + + $stmt->bindParam(':token_hash', $tokenHash); + + $stmt->execute(); + + $row = $stmt->fetch(); + if (!$row) { + return null; + } + + $token = new RefreshTokenData(); + $token->tokenHash = $tokenHash; + $token->userId = $row['user_id']; + $token->clientId = $row['client_id']; + $token->creationTime = $row['creation_time']; + $token->expirationTime = $row['expiration_time']; + + $scope = null; + if (isset($row['scope'])) { + $scope = $row['scope']; + } + if ($scope !== null && $scope !== '') { + $token->scope = explode(' ', $scope); + } + + return $token; + } + + function deleteRefreshTokenData(string $tokenHash): void { + + $dbh = $this->getDBHandler(); + + $stmt = $dbh->prepare("DELETE FROM refresh_token WHERE token_hash = :token_hash"); + $stmt->bindParam(':token_hash', $tokenHash); + $stmt->execute(); + } + +} diff --git a/classes/MySQLDAO.php b/classes/datalayer/mysql/MySQLUserDAO.php similarity index 66% rename from classes/MySQLDAO.php rename to classes/datalayer/mysql/MySQLUserDAO.php index f81186d7ddb042f8046157c22bd6209d90282f7b..14f8c0d283e118c7046821c260c7375f2a8fcbdf 100644 --- a/classes/MySQLDAO.php +++ b/classes/datalayer/mysql/MySQLUserDAO.php @@ -24,68 +24,13 @@ namespace RAP; -use PDO; - /** * MySQL implementation of the DAO interface. See comments on the DAO interface. */ -class MySQLDAO implements DAO { - - private $config; - - public function __construct($config) { - $this->config = $config; - } - - public function getDBHandler() { - $connectionString = "mysql:host=" . $this->config['hostname'] . ";dbname=" . $this->config['dbname']; - $dbh = new PDO($connectionString, $this->config['username'], $this->config['password']); - // For transaction errors (see https://stackoverflow.com/a/9659366/771431) - $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - return $dbh; - } - - public function createLoginToken($token, $userId) { - - $dbh = $this->getDBHandler(); - $stmt = $dbh->prepare("INSERT INTO login_token (token, user_id) VALUES(:token, :user_id)"); - - $params = array( - ':token' => $token, - ':user_id' => $userId - ); - - if ($stmt->execute($params)) { - return $token; - } else { - error_log($stmt->errorInfo()[2]); - throw new \Exception("SQL error while storing user token"); - } - } - - public function findLoginToken($token) { - - $dbh = $this->getDBHandler(); - - $stmt = $dbh->prepare("SELECT user_id FROM login_token WHERE token = :token AND CURRENT_TIMESTAMP < TIMESTAMPADD(MINUTE,1,creation_time)"); - $stmt->bindParam(':token', $token); - - $stmt->execute(); - - foreach ($stmt->fetchAll() as $row) { - return $row['user_id']; - } - - return null; - } - - public function deleteLoginToken($token) { - - $dbh = $this->getDBHandler(); +class MySQLUserDAO extends BaseMySQLDAO implements UserDAO { - $stmt = $dbh->prepare("DELETE FROM login_token WHERE token = :token"); - $stmt->bindParam(':token', $token); - $stmt->execute(); + public function __construct(Locator $locator) { + parent::__construct($locator); } public function insertIdentity(Identity $identity, $userId) { @@ -124,7 +69,7 @@ class MySQLDAO implements DAO { $identity = new Identity($row['type']); $identity->id = $row['id']; - $identity->primary = $row['primary']; + $identity->primary = boolval($row['primary']); $identity->typedId = $row['typed_id']; $identity->email = $row['email']; $identity->name = $row['name']; @@ -135,7 +80,7 @@ class MySQLDAO implements DAO { return $identity; } - public function findUserById($userId) { + public function findUserById(string $userId): ?User { if (!filter_var($userId, FILTER_VALIDATE_INT)) { return null; @@ -229,6 +174,47 @@ class MySQLDAO implements DAO { $stmt->bindParam(':surname', $searchParam); $stmt->bindParam(':namesurname', $searchParam); + return $this->getUsersListFromStatement($stmt); + } + + public function getUsers(array $identifiers): array { + + if (count($identifiers) === 0) { + return []; + } + + $dbh = $this->getDBHandler(); + + $query = "SELECT `user_id`, (u.`primary_identity` = i.`id`) AS `primary`," + . " i.`id`, `type`, `typed_id`, `email`, `name`, `surname`, `institution`, `eppn`" + . " FROM identity i" + . " JOIN `user` u on u.id = i.user_id" + . " WHERE i.user_id IN ("; + + $first = true; + foreach ($identifiers as $id) { + if (!$first) { + $query .= ','; + } + $query .= ':id_' . $id; + if ($first) { + $first = !$first; + } + } + + $query .= ')'; + + $stmt = $dbh->prepare($query); + + foreach ($identifiers as &$id) { + $stmt->bindParam(':id_' . $id, $id); + } + + return $this->getUsersListFromStatement($stmt); + } + + private function getUsersListFromStatement(\PDOStatement $stmt): array { + $stmt->execute(); $userMap = array(); @@ -256,44 +242,6 @@ class MySQLDAO implements DAO { return $users; } - public function createJoinRequest($token, $applicantUserId, $targetUserId) { - - if ($applicantUserId === $targetUserId) { - throw new \Exception("Invalid target user id"); - } - - $dbh = $this->getDBHandler(); - - $stmt = $dbh->prepare("INSERT INTO `join_request`(`token`, `applicant_user_id`, `target_user_id`)" - . " VALUES(:token, :applicant_user_id, :target_user_id)"); - - $stmt->bindParam(':token', $token); - $stmt->bindParam(':applicant_user_id', $applicantUserId); - $stmt->bindParam(':target_user_id', $targetUserId); - - $stmt->execute(); - } - - public function findJoinRequest($token) { - $dbh = $this->getDBHandler(); - - $stmt = $dbh->prepare("SELECT `applicant_user_id`, `target_user_id` FROM `join_request` WHERE `token` = :token"); - $stmt->bindParam(':token', $token); - $stmt->execute(); - - $result = $stmt->fetchAll(); - - switch (count($result)) { - case 0: - return null; - case 1: - $row = $result[0]; - return [$row['applicant_user_id'], $row['target_user_id']]; - default: - throw new Exception("Found multiple join request with the same token"); - } - } - public function joinUsers($userId1, $userId2) { $dbh = $this->getDBHandler(); @@ -306,16 +254,10 @@ class MySQLDAO implements DAO { $stmt1->bindParam(':id2', $userId2); $stmt1->execute(); - // Deleting user2 join requests - $stmt3 = $dbh->prepare("DELETE FROM `join_request` WHERE `target_user_id` = :tid2 OR `applicant_user_id` = :aid2"); - $stmt3->bindParam(':tid2', $userId2); - $stmt3->bindParam(':aid2', $userId2); - $stmt3->execute(); - // Deleting user2 - $stmt4 = $dbh->prepare("DELETE FROM `user` WHERE `id` = :id2"); - $stmt4->bindParam(':id2', $userId2); - $stmt4->execute(); + $stmt2 = $dbh->prepare("DELETE FROM `user` WHERE `id` = :id2"); + $stmt2->bindParam(':id2', $userId2); + $stmt2->execute(); $dbh->commit(); } catch (Exception $ex) { @@ -324,12 +266,19 @@ class MySQLDAO implements DAO { } } - public function deleteJoinRequest($token) { + function isAdmin($userId): bool { + $dbh = $this->getDBHandler(); - $stmt = $dbh->prepare("DELETE FROM `join_request` WHERE `token` = :token"); - $stmt->bindParam(':token', $token); + $query = "SELECT user_id FROM rap_permissions WHERE permission = 'ADMIN' AND user_id = :userId"; + + $stmt = $dbh->prepare($query); + $stmt->bindParam(':userId', $userId); $stmt->execute(); + + $result = $stmt->fetchAll(); + + return count($result) === 1; } } diff --git a/classes/exceptions/BadRequestException.php b/classes/exceptions/BadRequestException.php new file mode 100644 index 0000000000000000000000000000000000000000..fb7576e6f534b692466d6c872a11dec8a18bb7bc --- /dev/null +++ b/classes/exceptions/BadRequestException.php @@ -0,0 +1,13 @@ +<?php + +namespace RAP; + +class BadRequestException extends \Exception { + + public $message; + + public function __construct($message) { + $this->message = $message; + } + +} diff --git a/classes/exceptions/UnauthorizedException.php b/classes/exceptions/UnauthorizedException.php new file mode 100644 index 0000000000000000000000000000000000000000..27cd752d71ea6b37a79495071ef116dffd4cbf01 --- /dev/null +++ b/classes/exceptions/UnauthorizedException.php @@ -0,0 +1,13 @@ +<?php + +namespace RAP; + +class UnauthorizedException extends \Exception { + + public $message; + + public function __construct($message) { + $this->message = $message; + } + +} diff --git a/classes/login/FacebookLogin.php b/classes/login/FacebookLogin.php new file mode 100644 index 0000000000000000000000000000000000000000..8e5dc8438f3d7bda5c476306342fc9cdf932102f --- /dev/null +++ b/classes/login/FacebookLogin.php @@ -0,0 +1,97 @@ +<?php + +namespace RAP; + +class FacebookLogin extends LoginHandler { + + public function __construct(Locator $locator) { + parent::__construct($locator, Identity::FACEBOOK); + } + + public function login(): string { + + // Retrieve Facebook configuration + $Facebook = $this->locator->config->authenticationMethods->Facebook; + + $fb = new \Facebook\Facebook([ + 'app_id' => $Facebook->id, + 'app_secret' => $Facebook->secret, + 'default_graph_version' => $Facebook->version, + ]); + + $helper = $fb->getRedirectLoginHelper(); + + $permissions = ['email']; // Optional permissions: we need user email + + $loginUrl = $helper->getLoginUrl($this->locator->getBasePath() . $Facebook->callback, $permissions); + + return $loginUrl; + } + + public function retrieveToken(): string { + // Retrieve Facebook configuration + $Facebook = $this->locator->config->authenticationMethods->Facebook; + + $fb = new \Facebook\Facebook([ + 'app_id' => $Facebook->id, + 'app_secret' => $Facebook->secret, + 'default_graph_version' => $Facebook->version, + ]); + + $helper = $fb->getRedirectLoginHelper(); + if (isset($_GET['state'])) { + $helper->getPersistentDataHandler()->set('state', $_GET['state']); + } + + try { + // obtaining current URL without query string + $url = "https://$_SERVER[HTTP_HOST]" . strtok($_SERVER["REQUEST_URI"], '?'); + $accessToken = $helper->getAccessToken($url); + } catch (Facebook\Exceptions\FacebookResponseException $e) { + // When Graph returns an error + http_response_code(500); + die('Graph returned an error: ' . $e->getMessage()); + } catch (Facebook\Exceptions\FacebookSDKException $e) { + // When validation fails or other local issues + http_response_code(500); + die('Facebook SDK returned an error: ' . $e->getMessage()); + } + if (!isset($accessToken)) { + if ($helper->getError()) { + $errorMessage = "Error: " . $helper->getError() . "<br>"; + $errorMessage = $errorMessage . "Error Code: " . $helper->getErrorCode() . "<br>"; + $errorMessage = $errorMessage . "Error Reason: " . $helper->getErrorReason() . "<br>"; + $errorMessage = $errorMessage . "Error Description: " . $helper->getErrorDescription(); + } else { + $errorMessage = "Bad request"; + } + + http_response_code(500); + die($errorMessage); + } + + try { + // Returns a `Facebook\FacebookResponse` object + $response = $fb->get('/me?fields=id,first_name,last_name,email', $accessToken); + } catch (Facebook\Exceptions\FacebookResponseException $e) { + echo 'Graph returned an error: ' . $e->getMessage(); + exit; + } catch (Facebook\Exceptions\FacebookSDKException $e) { + echo 'Facebook SDK returned an error: ' . $e->getMessage(); + exit; + } + + $_SESSION['fb_access_token'] = (string) $accessToken; + + $fbUser = $response->getGraphUser(); + + $typedId = $fbUser["id"]; + + return $this->onIdentityDataReceived($typedId, function($identity) use($fbUser) { + $identity->email = $fbUser["email"]; + $identity->name = $fbUser["first_name"]; + $identity->surname = $fbUser["last_name"]; + }); + } + +} diff --git a/classes/login/GoogleLogin.php b/classes/login/GoogleLogin.php new file mode 100644 index 0000000000000000000000000000000000000000..82079aae58bb820a21216e121b8cff8007ed5a89 --- /dev/null +++ b/classes/login/GoogleLogin.php @@ -0,0 +1,74 @@ +<?php + +namespace RAP; + +class GoogleLogin extends LoginHandler { + + public function __construct(Locator $locator) { + parent::__construct($locator, Identity::GOOGLE); + } + + public function login() { + // Retrieve Google configuration + + $Google = $this->locator->config->authenticationMethods->Google; + + $client = new \Google_Client(array( + 'client_id' => $Google->id, + 'client_secret' => $Google->secret, + 'redirect_uri' => $this->locator->getBasePath() . $Google->callback, + )); + + // Ask permission to obtain user email and profile information + $client->setScopes(array(\Google_Service_People::USERINFO_EMAIL, \Google_Service_People::USERINFO_PROFILE)); + + if (isset($_REQUEST['logout'])) { + // Reset the access token stored into the session + unset($_SESSION['access_token']); + } + + if (isset($_GET['code'])) { + // An access token has been returned from the auth URL. + $client->authenticate($_GET['code']); + $_SESSION['access_token'] = $client->getAccessToken(); + } + + if ($client->getAccessToken()) { + + // Query web service for retrieving user information + $service = new \Google_Service_People($client); + + try { + $res = $service->people->get('people/me', array('requestMask.includeField' => 'person.names,person.email_addresses')); + } catch (Google_Service_Exception $e) { + echo '<p>' . json_encode($e->getErrors()) . '</p>'; + $thisPage = $PROTOCOL . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF']; + echo '<p><a href="' . $thisPage . '?logout">Click here to unset the access token</a></p>'; + } + + $name = $res->getNames()[0]->getGivenName(); + $surname = $res->getNames()[0]->getFamilyName(); + + $emailAddresses = []; + foreach ($res->getEmailAddresses() as $addr) { + array_push($emailAddresses, $addr->value); + } + + $typedId = explode('/', $res->getResourceName())[1]; + + return $this->onIdentityDataReceived($typedId, function($identity) use($emailAddresses, $name, $surname) { + $identity->email = $emailAddresses[0]; + $identity->name = $name; + $identity->surname = $surname; + }); + } else { + // Redirect to Google authorization URL for obtaining an access token + $authUrl = $client->createAuthUrl(); + header('Location: ' . $authUrl); + die(); + } + + return null; + } + +} diff --git a/classes/login/LinkedInLogin.php b/classes/login/LinkedInLogin.php new file mode 100644 index 0000000000000000000000000000000000000000..57c4f3b33ec6d73907b92fc588b27595c7ca2330 --- /dev/null +++ b/classes/login/LinkedInLogin.php @@ -0,0 +1,148 @@ +<?php + +namespace RAP; + +class LinkedInLogin extends LoginHandler { + + public function __construct(Locator $locator) { + parent::__construct($locator, Identity::LINKEDIN); + } + + public function login(): string { + // Retrieve LinkedIn configuration + $LinkedIn = $this->locator->config->authenticationMethods->LinkedIn; + + $url = "https://www.linkedin.com/oauth/v2/authorization?response_type=code"; + $url .= "&client_id=" . $LinkedIn->id; + $url .= "&redirect_uri=" . $this->locator->getBasePath() . $LinkedIn->callback; + $url .= "&state=" . bin2hex(random_bytes(5)); + $url .= "&scope=r_liteprofile%20r_emailaddress%20w_member_social"; + + return $url; + } + + public function retrieveToken(): string { + // Retrieve LinkedIn configuration + $LinkedIn = $this->locator->config->authenticationMethods->LinkedIn; + + if (!isset($_REQUEST['code'])) { + die("Unable to get LinkedIn client code"); + } + + //create array of data to be posted to get AccessToken + $post_data = array( + 'grant_type' => "authorization_code", + 'code' => $_REQUEST['code'], + 'redirect_uri' => $this->locator->getBasePath() . $LinkedIn->callback, + 'client_id' => $LinkedIn->id, + 'client_secret' => $LinkedIn->secret + ); + + //traverse array and prepare data for posting (key1=value1) + foreach ($post_data as $key => $value) { + $post_items[] = $key . '=' . $value; + } + + //create the final string to be posted + $post_string = implode('&', $post_items); + + //create cURL connection + $conn1 = curl_init('https://www.linkedin.com/oauth/v2/accessToken'); + + //set options + curl_setopt($conn1, CURLOPT_CONNECTTIMEOUT, 30); + curl_setopt($conn1, CURLOPT_RETURNTRANSFER, true); + curl_setopt($conn1, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($conn1, CURLOPT_FOLLOWLOCATION, 1); + + //set data to be posted + curl_setopt($conn1, CURLOPT_POSTFIELDS, $post_string); + + //perform our request + $result1 = curl_exec($conn1); + $info1 = curl_getinfo($conn1); + + if ($info1['http_code'] === 200) { + $my_token = json_decode($result1, TRUE); + $access_token = $my_token['access_token']; + $expires_in = $my_token['expires_in']; + curl_close($conn1); + } else { + //show information regarding the error + $errorMessage = "Error: LinkedIn server response code: " . $info1['http_code'] . " - "; + $errorMessage .= curl_error($conn1); + error_log($result1); + curl_close($conn1); + http_response_code(500); + die($errorMessage); + } + + // Call to API + $conn2 = curl_init(); + curl_setopt($conn2, CURLOPT_URL, "https://api.linkedin.com/v2/me"); + curl_setopt($conn2, CURLOPT_HTTPHEADER, array( + 'Authorization: Bearer ' . $access_token + )); + + curl_setopt($conn2, CURLOPT_RETURNTRANSFER, true); + $result = curl_exec($conn2); + $info2 = curl_getinfo($conn2); + + if ($info2['http_code'] === 200) { + $data = json_decode($result, TRUE); + + curl_close($conn2); + + if (isset($data['errorCode'])) { + $errorMessage = $data['message']; + die($errorMessage); + } + + $typedId = $data['id']; + + // Recall to API for email + $conn2 = curl_init(); + curl_setopt($conn2, CURLOPT_URL, "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))"); + curl_setopt($conn2, CURLOPT_HTTPHEADER, array( + 'Authorization: Bearer ' . $access_token + )); + + curl_setopt($conn2, CURLOPT_RETURNTRANSFER, true); + $result = curl_exec($conn2); + $info2 = curl_getinfo($conn2); + + if ($info2['http_code'] === 200) { + $data2 = json_decode($result, TRUE); + + curl_close($conn2); + + if (isset($data['errorCode'])) { + $errorMessage = $data['message']; + die($errorMessage); + } + } else { + //show information regarding the error + $errorMessage = "Error: LinkedIn server response code: " . $info2['http_code'] . " - "; + $errorMessage = $errorMessage . curl_error($conn2); + curl_close($conn2); + die($errorMessage); + } + + return $this->onIdentityDataReceived($typedId, function($identity) use($data, $data2) { + $identity->email = $data2['elements'][0]['handle~']['emailAddress']; + $identity->name = $data['localizedFirstName']; + $identity->surname = $data['localizedLastName']; + }); + } else { + //show information regarding the error + $errorMessage = "Error: LinkedIn server response code: " . $info2['http_code'] . " - "; + $errorMessage = $errorMessage . curl_error($conn2); + error_log($result); + curl_close($conn2); + die($errorMessage); + } + + return null; + } + +} diff --git a/classes/login/LoginHandler.php b/classes/login/LoginHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..9ebdf218813fc5c4a75c31556d30e562734fbb1c --- /dev/null +++ b/classes/login/LoginHandler.php @@ -0,0 +1,107 @@ +<?php + +namespace RAP; + +class LoginHandler { + + protected $locator; + private $identityType; + + public function __construct(Locator $locator, string $identityType) { + $this->locator = $locator; + $this->identityType = $identityType; + } + + public function onIdentityDataReceived(string $typedId, \Closure $fillIdentityData): string { + + $user = $this->locator->getUserDAO()->findUserByIdentity($this->identityType, $typedId); + + if ($user === null) { + return $this->handleNewIdentity($typedId, $fillIdentityData); + } + + return $this->getAfterLoginRedirect($user); + } + + protected function handleNewIdentity(string $typedId, \Closure $fillIdentityData): string { + + $session = $this->locator->getSession(); + + if ($session->getUser() !== null && $session->getAction() === 'join') { + return $this->joinToPreviousUser($session->getUser(), $typedId, $fillIdentityData); + } else { + return $this->redirectToTOUCheck($typedId, $fillIdentityData); + } + } + + private function joinToPreviousUser(User $user, string $typedId, \Closure $fillIdentityData): string { + + $identity = new Identity($this->identityType); + $identity->typedId = $typedId; + $fillIdentityData($identity); + + $user->addIdentity($identity); + + $this->locator->getUserHandler()->saveUser($user); + + $this->locator->getSession()->setUser($user); + + return $this->getAfterLoginRedirect($user); + } + + /** + * Stores the data into session and Redirect to Term of Use acceptance page. + */ + private function redirectToTOUCheck(string $typedId, \Closure $fillIdentityData): string { + + // Create new user + $user = new \RAP\User(); + + $identity = new Identity($this->identityType); + $identity->typedId = $typedId; + $fillIdentityData($identity); + + $user->addIdentity($identity); + + $this->locator->getSession()->setUser($user); + + return $this->locator->getBasePath() . '/tou-check'; + } + + public function getAfterLoginRedirect(User $user): string { + + $session = $this->locator->getSession(); + $this->locator->getAuditLogger()->info("LOGIN," . $this->identityType . "," . $user->id); + + if ($session->getOAuth2RequestData() !== null) { + $session->setUser($user); + $redirectUrl = $this->locator->getOAuth2RequestHandler()->getRedirectResponseUrl(); + session_destroy(); + return $redirectUrl; + } + + if ($session->getAction() !== null) { + + $action = $session->getAction(); + + if ($action === 'join') { + if ($session->getUser()->id !== $user->id) { + $user = $this->locator->getUserHandler()->joinUsers($session->getUser(), $user); + } + + // the join is completed + $action = 'account'; + $session->setAction($action); + } + + $session->setUser($user); + + if ($action === 'account' || $action === 'admin') { + return $this->locator->getBasePath() . '/' . $action; + } + } + + throw new \Exception("Unable to find a proper redirect"); + } + +} diff --git a/classes/login/OrcidLogin.php b/classes/login/OrcidLogin.php new file mode 100644 index 0000000000000000000000000000000000000000..8568a73e48cbb26494ab732852228270b7860e52 --- /dev/null +++ b/classes/login/OrcidLogin.php @@ -0,0 +1,127 @@ +<?php + +namespace RAP; + +class OrcidLogin extends LoginHandler { + + public function __construct(Locator $locator) { + parent::__construct($locator, Identity::ORCID); + } + + public function login(): string { + + $ORCID = $this->locator->config->authenticationMethods->OrcID; + + $url = "https://orcid.org/oauth/authorize"; + $url = $url . "?client_id=" . $ORCID->id; + $url = $url . "&response_type=code"; + $url = $url . "&scope=/authenticate"; + $url = $url . "&redirect_uri=" . $this->locator->getBasePath() . $ORCID->callback; + + return $url; + } + + public function retrieveToken($code) { + + if ($code === null) { + throw new BadRequestException("Unable to get ORCID client code"); + } + + $token = $this->getAccessTokenFromCode($code); + + $access_token = $token['access_token']; + $expires_in = $token['expires_in']; + $orcid_id = $username = $token['orcid']; + + $orcid_data = $this->getOrcidDataUsingAccessToken($orcid_id, $access_token); + + if (!isset($orcid_data['person']['emails']['email'][0]['email'])) { + throw new \Exception("ORCID didn't return the email"); + } + + return $this->onIdentityDataReceived($orcid_id, function($identity) use($orcid_data) { + $identity->email = $email = $orcid_data['person']['emails']['email'][0]['email']; + $identity->name = $orcid_data['person']['name']['given-names']['value']; + $identity->surname = $orcid_data['person']['name']['family-name']['value']; + $employmentSummary = $orcid_data['activities-summary']['employments']['employment-summary']; + if (count($employmentSummary) > 0) { + $identity->institution = $employmentSummary[0]['organization']['name']; + } + }); + } + + private function getAccessTokenFromCode($code): array { + + $ORCID = $this->locator->config->authenticationMethods->OrcID; + + //create array of data to be posted to get AccessToken + $post_data = array( + 'grant_type' => "authorization_code", + 'code' => $code, + 'redirect_uri' => $this->locator->getBasePath() . $ORCID->callback, + 'client_id' => $ORCID->id, + 'client_secret' => $ORCID->secret + ); + + //traverse array and prepare data for posting (key1=value1) + foreach ($post_data as $key => $value) { + $post_items[] = $key . '=' . $value; + } + + //create the final string to be posted + $post_string = implode('&', $post_items); + + //create cURL connection + $conn = curl_init('https://orcid.org/oauth/token'); + + //set options + curl_setopt($conn, CURLOPT_CONNECTTIMEOUT, 30); + curl_setopt($conn, CURLOPT_RETURNTRANSFER, true); + curl_setopt($conn, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($conn, CURLOPT_FOLLOWLOCATION, 1); + + //set data to be posted + curl_setopt($conn, CURLOPT_POSTFIELDS, $post_string); + + //perform our request + $result = curl_exec($conn); + + if ($result) { + $token = json_decode($result, TRUE); + curl_close($conn); + return $token; + } else { + //show information regarding the error + $errorMessage = curl_errno($conn) . "-"; + $errorMessage = $errorMessage . curl_error($conn); + curl_close($conn); + throw new \Exception($errorMessage); + } + } + + private function getOrcidDataUsingAccessToken(string $orcid_id, string $access_token) { + + // API call + $orcid_url = "https://pub.orcid.org/v2.1/" . $orcid_id . "/record"; + $conn = curl_init(); + curl_setopt($conn, CURLOPT_URL, $orcid_url); + curl_setopt($conn, CURLOPT_HTTPHEADER, array( + 'Authorization: Bearer ' . $access_token, + 'Accept: application/json')); + + curl_setopt($conn, CURLOPT_RETURNTRANSFER, true); + $result = curl_exec($conn); + + if ($result) { + $orcid_data = json_decode($result, TRUE); + curl_close($conn); + return $orcid_data; + } else { + $errorMessage = curl_errno($conn) . "-"; + $errorMessage = $errorMessage . curl_error($conn); + curl_close($conn); + throw new \Exception($errorMessage); + } + } + +} diff --git a/classes/login/ShibbolethLogin.php b/classes/login/ShibbolethLogin.php new file mode 100644 index 0000000000000000000000000000000000000000..0d08f8f512e116d1e6d229df2901a959b98ff9f4 --- /dev/null +++ b/classes/login/ShibbolethLogin.php @@ -0,0 +1,35 @@ +<?php + +namespace RAP; + +class ShibbolethLogin extends LoginHandler { + + public function __construct(Locator $locator) { + parent::__construct($locator, Identity::EDU_GAIN); + } + + public function login() { + + if (isset($_SERVER['Shib-Session-ID'])) { + + // Retrieving eduPersonPrincipalName (eppn) + $eppn = $_SERVER['eppn']; + + // Search if the user is already registered into RAP using the eppn. + // The persistent id should be a more appropriate identifier, however at IA2 + // we need to import all INAF user into RAP, even if they will never register, + // and in that case we know only their eppn. + + return $this->onIdentityDataReceived($eppn, function($identity) use($eppn) { + $identity->email = $_SERVER['mail']; + $identity->name = $_SERVER['givenName']; + $identity->surname = $_SERVER['sn']; + $identity->eppn = $eppn; + }); + } else { + http_response_code(500); + die("Shib-Session-ID not found!"); + } + } + +} diff --git a/classes/login/X509Login.php b/classes/login/X509Login.php new file mode 100644 index 0000000000000000000000000000000000000000..b050bbacf4e653c625091223fa393901cae5fb41 --- /dev/null +++ b/classes/login/X509Login.php @@ -0,0 +1,70 @@ +<?php + +namespace RAP; + +class X509Login extends LoginHandler { + + private $x509Data; + + public function __construct(Locator $locator) { + parent::__construct($locator, Identity::X509); + } + + public function login(): string { + if (isset($_SERVER['SSL_CLIENT_VERIFY']) && isset($_SERVER['SSL_CLIENT_V_REMAIN']) && + $_SERVER['SSL_CLIENT_VERIFY'] === 'SUCCESS' && $_SERVER['SSL_CLIENT_V_REMAIN'] > 0) { + + $x509Data = X509Data::parse($_SERVER); + $this->x509Data = $x509Data; + + return $this->onIdentityDataReceived($x509Data->serialNumber, function($identity) use ($x509Data) { + $this->fillIdentity($identity, $x509Data); + }); + } else { + http_response_code(500); + die("Unable to verify client certificate"); + } + + return null; + } + + public function afterNameSurnameSelection($x509Data) { + $redirect = $this->onIdentityDataReceived($x509Data->serialNumber, function($identity) use ($x509Data) { + $this->fillIdentity($identity, $x509Data); + }); + + $session = $this->locator->getSession(); + $session->setX509DataToRegister(null); + + return $redirect; + } + + private function fillIdentity($identity, $x509Data) { + $identity->email = $x509Data->email; + $identity->name = $x509Data->name; + $identity->surname = $x509Data->surname; + $identity->institution = $x509Data->institution; + } + + /** + * We want to extract name and surname from the X.509 certificate, however X.509 + * puts name and surname together (inside the CN field). + * If name and surname are single words it is possible to retrieve them splitting + * on the space character, otherwise the user has to choose the correct combination. + * In that case partial X.509 data is temporarily stored into the user session and + * the page views/x509-name-surname.php is shown to the user before completing the + * registration, in order to allow him/her selecting the correct name and surname. + */ + protected function handleNewIdentity(string $typedId, \Closure $fillIdentityData): string { + + $session = $this->locator->getSession(); + + if ($this->x509Data->name === null) { + $session->setX509DataToRegister($this->x509Data); + return $this->locator->getBasePath() . '/x509-name-surname'; + } else { + return parent::handleNewIdentity($typedId, $fillIdentityData); + } + } + +} diff --git a/classes/model/AccessTokenData.php b/classes/model/AccessTokenData.php new file mode 100644 index 0000000000000000000000000000000000000000..3826e22a14a040dceb6464a8b2969f58c891e9e8 --- /dev/null +++ b/classes/model/AccessTokenData.php @@ -0,0 +1,32 @@ +<?php + +namespace RAP; + +/** + * Data related to access tokens stored into the database. This object is + * retrieved from the database (using the code or the refresh token hashes) and + * it is used to generate OAuth2 tokens. + */ +class AccessTokenData { + + private const TOKEN_LIFESPAN = 3600; + + public $id; + public $codeHash; + public $userId; + public $creationTime; + public $expirationTime; + public $redirectUri; + public $clientId; + public $scope; + + public function __construct() { + $this->creationTime = time(); + $this->expirationTime = $this->creationTime + AccessTokenData::TOKEN_LIFESPAN; + } + + public function isExpired(): bool { + return $this->expirationTime < time(); + } + +} diff --git a/classes/model/AuthPageModel.php b/classes/model/AuthPageModel.php new file mode 100644 index 0000000000000000000000000000000000000000..bf90c89f2b2d5baf68f675cfc45bd827dc580b5e --- /dev/null +++ b/classes/model/AuthPageModel.php @@ -0,0 +1,63 @@ +<?php + +namespace RAP; + +/** + * Model for the main RAP page (authentication method choice). + */ +class AuthPageModel { + + // boolean flags + public $eduGAIN; + public $orcid; + public $x509; + public $google; + public $facebook; + public $linkedIn; + public $localIdP; + // + public $clientIcon; + public $clientTitle; + public $localIdPConfig; + + public function __construct(\RAP\Locator $locator, \RAP\RAPClient $client) { + + $config = $locator->config; + + $this->setupAuthenticationMethodFlags($config, $client); + + if ($this->localIdP) { + $this->localIdPConfig = $config->authenticationMethods->LocalIdP; + } + + if (isset($client->icon)) { + $this->clientIcon = $client->getIconBasePath() . $client->icon; + } + $this->clientTitle = $client->title; + } + + private function setupAuthenticationMethodFlags($config, $client) { + + $this->eduGAIN = isset($config->authenticationMethods->eduGAIN) && + in_array(AuthenticationMethods::EDU_GAIN, $client->authMethods); + + $this->orcid = isset($config->authenticationMethods->OrcID) && + in_array(AuthenticationMethods::ORCID, $client->authMethods); + + $this->x509 = isset($config->authenticationMethods->X509) && + in_array(AuthenticationMethods::X509, $client->authMethods); + + $this->google = isset($config->authenticationMethods->Google) && + in_array(AuthenticationMethods::GOOGLE, $client->authMethods); + + $this->facebook = isset($config->authenticationMethods->Facebook) && + in_array(AuthenticationMethods::FACEBOOK, $client->authMethods); + + $this->linkedIn = isset($config->authenticationMethods->LinkedIn) && + in_array(AuthenticationMethods::LINKED_IN, $client->authMethods); + + $this->localIdP = isset($config->authenticationMethods->LocalIdP) && + in_array(AuthenticationMethods::LOCAL_IDP, $client->authMethods); + } + +} diff --git a/classes/model/AuthenticationMethods.php b/classes/model/AuthenticationMethods.php new file mode 100644 index 0000000000000000000000000000000000000000..8b3f29d59053021b427c6a6fbe3a483aafd88abc --- /dev/null +++ b/classes/model/AuthenticationMethods.php @@ -0,0 +1,27 @@ +<?php + +namespace RAP; + +abstract class AuthenticationMethods { + + const EDU_GAIN = "eduGAIN"; + const ORCID = "OrcID"; + const X509 = "X.509"; + const GOOGLE = "Google"; + const LINKED_IN = "LinkedIn"; + const FACEBOOK = "Facebook"; + const LOCAL_IDP = "LocalIdP"; + + public static function getAllMethods() { + return [ + AuthenticationMethods::EDU_GAIN, + AuthenticationMethods::ORCID, + AuthenticationMethods::X509, + AuthenticationMethods::GOOGLE, + AuthenticationMethods::LINKED_IN, + AuthenticationMethods::FACEBOOK, + AuthenticationMethods::LOCAL_IDP + ]; + } + +} diff --git a/classes/Identity.php b/classes/model/Identity.php similarity index 98% rename from classes/Identity.php rename to classes/model/Identity.php index 7710595fe534008d4fc00b0cdfb05c53971fb6cc..e0ba59240301dac14857cbdba6a838429cc64767 100644 --- a/classes/Identity.php +++ b/classes/model/Identity.php @@ -34,8 +34,9 @@ class Identity { const GOOGLE = "Google"; const FACEBOOK = "Facebook"; const LINKEDIN = "LinkedIn"; + const ORCID = "OrcID"; - private static $ALLOWED_TYPES = [Identity::EDU_GAIN, Identity::X509, Identity::GOOGLE, Identity::FACEBOOK, Identity::LINKEDIN]; + private static $ALLOWED_TYPES = [Identity::EDU_GAIN, Identity::X509, Identity::GOOGLE, Identity::FACEBOOK, Identity::LINKEDIN, Identity::ORCID]; /** * Identity id in the database. Mandatory field. diff --git a/classes/model/InternalClient.php b/classes/model/InternalClient.php new file mode 100644 index 0000000000000000000000000000000000000000..f91f7d0077244fca781c50469a6dcdbdc64e66d6 --- /dev/null +++ b/classes/model/InternalClient.php @@ -0,0 +1,22 @@ +<?php + +namespace RAP; + +/** + * Represents a client that connects to parts of RAP itself (e.g. Account Manager). + * It doesn't use OAuth2, instead stores data directly into the PHP session. + */ +class InternalClient extends RAPClient { + + public $action; + + public function __construct(string $action) { + $this->authMethods = AuthenticationMethods::getAllMethods(); + $this->action = $action; + } + + public function getIconBasePath() { + return 'service-logos/'; + } + +} diff --git a/classes/Util.php b/classes/model/OAuth2Client.php similarity index 70% rename from classes/Util.php rename to classes/model/OAuth2Client.php index 4879b9b08d3005625644a1736b2202b030093860..acad698f384be71d3ae51df48f1d7389e514dd99 100644 --- a/classes/Util.php +++ b/classes/model/OAuth2Client.php @@ -6,7 +6,7 @@ * OATS - Astronomical Observatory - Trieste * ---------------------------------------------------------------------------- * - * Copyright (C) 2016 Istituto Nazionale di Astrofisica + * Copyright (C) 2019 Istituto Nazionale di Astrofisica * * This program is free software; you can redistribute it and/or modify it under * the terms of the GNU General Public License Version 3 as published by the @@ -25,16 +25,21 @@ namespace RAP; /** - * Utility class + * Data model for storing information about a RAP client connecting using OAuth2. */ -class Util { +class OAuth2Client extends RAPClient { - /** - * @return string random string - */ - public static function createNewToken() { - // Credits: http://stackoverflow.com/a/18890309/771431 - return bin2hex(openssl_random_pseudo_bytes(16)); + public $id; + public $client; + public $secret; + public $redirectUrl; + public $scope; + public $homePage; + public $showInHome; + public $scopeAudienceMap = []; + + public function getIconBasePath() { + return 'client-icons/'; } } diff --git a/classes/model/OAuth2RequestData.php b/classes/model/OAuth2RequestData.php new file mode 100644 index 0000000000000000000000000000000000000000..bac7778be8a6f9e72bba1c5ed1f071535ed20c6f --- /dev/null +++ b/classes/model/OAuth2RequestData.php @@ -0,0 +1,16 @@ +<?php + +namespace RAP; + +/** + * Data model for OAuth2 request. + */ +class OAuth2RequestData { + + public $clientId; + public $redirectUrl; + public $state; + public $scope; + public $nonce; + +} diff --git a/classes/model/RAPClient.php b/classes/model/RAPClient.php new file mode 100644 index 0000000000000000000000000000000000000000..e6a55e8fc30f0c4d777fe23ef9a4e33f400f92f2 --- /dev/null +++ b/classes/model/RAPClient.php @@ -0,0 +1,13 @@ +<?php + +namespace RAP; + +abstract class RAPClient { + + public $title; + public $icon; + // list of AuthN methods supported by the client + public $authMethods = []; + + public abstract function getIconBasePath(); +} diff --git a/classes/model/RSAKeyPair.php b/classes/model/RSAKeyPair.php new file mode 100644 index 0000000000000000000000000000000000000000..ac224585c130348be20a92f6152a8c9ac55faac5 --- /dev/null +++ b/classes/model/RSAKeyPair.php @@ -0,0 +1,12 @@ +<?php + +namespace RAP; + +class RSAKeyPair { + + public $keyId; + public $privateKey; + public $publicKey; + public $alg; + public $creationTime; +} \ No newline at end of file diff --git a/classes/model/RefreshTokenData.php b/classes/model/RefreshTokenData.php new file mode 100644 index 0000000000000000000000000000000000000000..22e18d18f30a6658f66ce07b2d18c36b86dfd685 --- /dev/null +++ b/classes/model/RefreshTokenData.php @@ -0,0 +1,25 @@ +<?php + +namespace RAP; + +class RefreshTokenData { + + private const TOKEN_LIFESPAN = 2 * 3600; + + public function __construct() { + $this->creationTime = time(); + $this->expirationTime = $this->creationTime + RefreshTokenData::TOKEN_LIFESPAN; + } + + public $tokenHash; + public $userId; + public $creationTime; + public $expirationTime; + public $clientId; + public $scope; + + public function isExpired(): bool { + return $this->expirationTime < time(); + } + +} diff --git a/classes/model/SessionData.php b/classes/model/SessionData.php new file mode 100644 index 0000000000000000000000000000000000000000..a560b6a03214691bcf9e194714372e5c0889f29c --- /dev/null +++ b/classes/model/SessionData.php @@ -0,0 +1,96 @@ +<?php + +/* ---------------------------------------------------------------------------- + * INAF - National Institute for Astrophysics + * IRA - Radioastronomical Institute - Bologna + * OATS - Astronomical Observatory - Trieste + * ---------------------------------------------------------------------------- + * + * Copyright (C) 2016 Istituto Nazionale di Astrofisica + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License Version 3 as published by the + * Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +namespace RAP; + +/** + * This class wraps various objects that need to be stored into the session in + * order to provide an object oriented transparent session management. + */ +class SessionData { + + const KEY = "SessionData"; + + private $user; + private $x509DataToRegister; + private $oauth2RequestData; + private $action; + + public function setUser(?User $user): void { + $this->user = $user; + $this->save(); + } + + public function getUser(): ?User { + return $this->user; + } + + /** + * Update the user data model stored into the session after the primary + * identity has changed, in order to avoid reading again the user data from + * the database. + * @param int $identityId + */ + public function updatePrimaryIdentity($identityId): void { + foreach ($this->user->identities as $identity) { + $identity->primary = ($identity->id === $identityId); + } + $this->save(); + } + + public function setX509DataToRegister(?X509Data $x509DataToRegister): void { + $this->x509DataToRegister = $x509DataToRegister; + $this->save(); + } + + public function getX509DataToRegister(): ?X509Data { + return $this->x509DataToRegister; + } + + public function setOAuth2RequestData(?OAuth2RequestData $oauth2RequestData): void { + $this->oauth2RequestData = $oauth2RequestData; + $this->save(); + } + + public function getOAuth2RequestData(): ?OAuth2RequestData { + return $this->oauth2RequestData; + } + + public function setAction(?string $action): void { + $this->action = $action; + $this->save(); + } + + public function getAction(): ?string { + return $this->action; + } + + /** + * Store the data into the $_SESSION PHP variable + */ + public function save() { + $_SESSION[SessionData::KEY] = $this; + } + +} diff --git a/classes/model/User.php b/classes/model/User.php new file mode 100644 index 0000000000000000000000000000000000000000..8f27e9c1a6627b31d705b7af90dcc663fdee83b6 --- /dev/null +++ b/classes/model/User.php @@ -0,0 +1,131 @@ +<?php + +/* ---------------------------------------------------------------------------- + * INAF - National Institute for Astrophysics + * IRA - Radioastronomical Institute - Bologna + * OATS - Astronomical Observatory - Trieste + * ---------------------------------------------------------------------------- + * + * Copyright (C) 2016 Istituto Nazionale di Astrofisica + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License Version 3 as published by the + * Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +namespace RAP; + +/** + * Data model for the user. An user is a set of identities. + */ +class User { + + // User ID + public $id; + // List of identities + public $identities; + + public function __construct() { + $this->identities = []; + } + + public function addIdentity(Identity $identity): void { + array_push($this->identities, $identity); + } + + public function getPrimaryEmail() { + foreach ($this->identities as $identity) { + if ($identity->primary) { + return $identity->email; + } + } + // A primary identity MUST be defined + throw new \Exception("No primary identity defined for user " . $this->id); + } + + /** + * Returns name and surname if they are present, preferring the primary identity data. + */ + public function getCompleteName(): ?string { + + $completeName = null; + + foreach ($this->identities as $identity) { + if ($identity->name !== null && $identity->surname !== null) { + $completeName = $identity->name . ' ' . $identity->surname; + } + if ($identity->primary && $completeName !== null) { + break; + } + } + + return $completeName; + } + + /** + * Returns the user name if it is present, preferring the primary identity data. + */ + public function getName(): ?string { + + $name = null; + + foreach ($this->identities as $identity) { + if ($identity->name !== null) { + $name = $identity->name; + } + if ($identity->primary && $name !== null) { + break; + } + } + + return $name; + } + + /** + * Returns the user surname if it is present, preferring the primary identity data. + */ + public function getSurname(): ?string { + + $surname = null; + + foreach ($this->identities as $identity) { + if ($identity->surname !== null) { + $surname = $identity->surname; + } + if ($identity->primary && $surname !== null) { + break; + } + } + + return $surname; + } + + /** + * Returns the user institution if it is present, preferring the primary identity data. + */ + public function getInstitution(): ?string { + + $institution = null; + + foreach ($this->identities as $identity) { + if ($identity->institution !== null) { + $institution = $identity->institution; + } + if ($identity->primary && $institution !== null) { + break; + } + } + + return $institution; + } + +} diff --git a/composer.json b/composer.json index a49af190f691b89e0b02da0048ed489b96ba7a15..41daa1a14bea5fe2192f5d5a46fe59163934fab1 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,20 @@ { + "name": "ia2/rap", + "description": "Remote Authentication Portal", + "license": "GPL-3.0-or-later", "require": { - "mikecao/flight": "1.3.2", + "mikecao/flight": "1.3.7", "google/apiclient": "2.1.3", "facebook/graph-sdk": "^5.5", "monolog/monolog": "^1.22", "phpmailer/phpmailer": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.2" + }, + "autoload": { + "classmap": [ + "classes/" + ] } } diff --git a/config-example.json b/config-example.json new file mode 100644 index 0000000000000000000000000000000000000000..4496e8a524c874d73c07868c75fc6d68d6976c6c --- /dev/null +++ b/config-example.json @@ -0,0 +1,58 @@ +{ + "contextRoot": "/rap-ia2", + "serviceLogFile": "/var/www/html/rap-ia2/logs/rap-service.log", + "auditLogFile": "/var/www/html/rap-ia2/logs/rap-audit.log", + "timeZone": "Europe/Rome", + "logLevel": "DEBUG", + "jwtIssuer": "sso.ia2.inaf.it", + "databaseConfig": { + "dbtype": "MySQL", + "hostname": "localhost", + "port": 3306, + "username": "rap", + "password": "XXXXXX", + "dbname": "rap" + }, + "authenticationMethods": { + "eduGAIN": {}, + "Google": { + "id": "XXXXXX", + "secret": "XXXXXX", + "callback": "/auth/social/google" + }, + "Facebook": { + "id": "XXXXXX", + "secret": "XXXXXX", + "version": "v3.0", + "callback": "/auth/social/facebook/token" + }, + "LinkedIn": { + "id": "XXXXXX", + "secret": "XXXXXX", + "callback": "/auth/social/linkedin/token" + }, + "X509": {}, + "LocalIdP": { + "url": "https://sso.ia2.inaf.it/Shibboleth.sso/Login?entityID=https://sso.ia2.inaf.it/idp/shibboleth&target=https://sso.ia2.inaf.it/rap-ia2/auth/saml2/aai.php", + "logo": "img/ia2-logo-60x60.png", + "logoAlt": "IA2 logo", + "description": "Use the IA2 Logo to Login if you have an account provided by IA2 or self registered" + }, + "OrcID": { + "id": "", + "callback": "/auth/orcid", + "secret": "" + } + }, + "gms": { + "id": "gms", + "joinEndpoint": "http://localhost:8082/gms/ws/jwt/join" + }, + "tokenIssuer": { + "services": [{ + "id": "fileserver", + "label": "File Server" + }], + "lifespans": [1, 6, 12, 24] + } +} diff --git a/config-example.php b/config-example.php index 5c2fabcb6fc794c4dcc10dc5febede097c9c5bdb..49b10f7a071183b4b1ec29c92fd40d5567af0a30 100644 --- a/config-example.php +++ b/config-example.php @@ -59,16 +59,16 @@ $AUTHENTICATION_METHODS = array( 'Google' => array( 'id' => "XXXXXX", 'secret' => "XXXXXX", - 'callback' => $BASE_PATH . "/auth/oauth2/google_token.php"), + 'callback' => $BASE_PATH . "/auth/social/google_token.php"), 'Facebook' => array( 'id' => "XXXXXX", 'secret' => "XXXXXX", 'version' => "v3.0", - 'callback' => $BASE_PATH . "/auth/oauth2/facebook_token.php"), + 'callback' => $BASE_PATH . "/auth/social/facebook_token.php"), 'LinkedIn' => array( 'id' => 'XXXXXX', 'secret' => 'XXXXXX', - 'callback' => $BASE_PATH . '/auth/oauth2/linkedin_token.php' + 'callback' => $BASE_PATH . '/auth/social/linkedin_token.php' ), 'X.509' => array(), 'DirectIdP' => array( diff --git a/css/style.css b/css/style.css index 021218b894fe81a217d6e3e322c0b01bc07b31b1..53aa45bb0555f9b05f478972580db200e04f0621 100644 --- a/css/style.css +++ b/css/style.css @@ -188,4 +188,32 @@ body { .service-logo { padding-right: 10px; max-height: 50px; +} + +#token-issuer-btn { + margin-top: 20px; + margin-bottom: 20px; +} + +.circle-wrapper { + text-align: center; + position: relative; +} + +.circle { + width: 90px; + height: 90px; + border-radius: 50%; + font-size: 25px; + color: #fff; + line-height: 30px; + text-align: center; + background: red; + transform: rotate(-20deg); + padding-top: 6px; + border: 3px yellow solid; + font-weight: bold; + position: absolute; + left: 27px; + top: 0px; } \ No newline at end of file diff --git a/exec/.htaccess b/exec/.htaccess new file mode 100644 index 0000000000000000000000000000000000000000..0819c675015a39eeb64ced3cc76d4cb17c3e37f8 --- /dev/null +++ b/exec/.htaccess @@ -0,0 +1,4 @@ +# This directory is for programmatical execution of php scripts +# e.g. generating new keypairs from a cron job + +Deny from all diff --git a/exec/generate-keypair.php b/exec/generate-keypair.php new file mode 100644 index 0000000000000000000000000000000000000000..beb7663f9979d5d7f1475d15f7a45e76bdf0c7e7 --- /dev/null +++ b/exec/generate-keypair.php @@ -0,0 +1,10 @@ +<?php + +chdir(dirname(__FILE__)); + +include '../include/init.php'; + +$handler = new \RAP\JWKSHandler($locator); +$handler->generateKeyPair(); + +echo "OK"; \ No newline at end of file diff --git a/exec/join.php b/exec/join.php new file mode 100644 index 0000000000000000000000000000000000000000..0f42b1bacb9cdfb64fa262951dde10b1c6509db5 --- /dev/null +++ b/exec/join.php @@ -0,0 +1,31 @@ +<?php + +if($argc !== 3) { + echo "Usage: php $argv[0] <user_id_1> <user_id_2>\n"; + echo "The second id will be deleted.\n"; + exit(1); +} + +chdir(dirname(__FILE__)); + +include '../include/init.php'; + +$dao = $locator->getUserDAO(); +$handler = $locator->getUserHandler(); +$tokenBuilder = $locator->getTokenBuilder(); + +$user1 = $dao->findUserById((int) $argv[1]); +if($user1 === null) { + echo "User $argv[1] not found"; + exit(1); +} + +$user2 = $dao->findUserById((int) $argv[2]); +if($user2 === null) { + echo "User $argv[2] not found"; + exit(1); +} + +$handler->joinUsers($user1, $user2); + +echo "OK\n"; diff --git a/img/eduGain-200.png b/img/eduGain-200.png deleted file mode 100755 index 5235aa8fcd435d54d4e4bec1374f6aa151a0c3d4..0000000000000000000000000000000000000000 Binary files a/img/eduGain-200.png and /dev/null differ diff --git a/img/edugain-100.png b/img/edugain-100.png new file mode 100644 index 0000000000000000000000000000000000000000..c24fbd52e91725f7686df9f23f6358274129ffeb Binary files /dev/null and b/img/edugain-100.png differ diff --git a/img/orcid-100.png b/img/orcid-100.png new file mode 100644 index 0000000000000000000000000000000000000000..d7ba058ba9c8f4fda93cca35c585a7c00b24499d Binary files /dev/null and b/img/orcid-100.png differ diff --git a/include/admin.php b/include/admin.php new file mode 100644 index 0000000000000000000000000000000000000000..3e3b7fafade74e09d1f090f98b17811460ff1711 --- /dev/null +++ b/include/admin.php @@ -0,0 +1,110 @@ +<?php + +/** + * Functionalities for the admin panel. + */ +// + +function checkUser() { + + session_start(); + global $locator; + + $session = $locator->getSession(); + if ($session->getUser() === null) { + http_response_code(401); + die("You must be registered to perform this action"); + } + + $dao = $locator->getUserDAO(); + if (!$dao->isAdmin($session->getUser()->id)) { + die("You must be an admin to perform this action"); + } +} + +Flight::route('GET /admin', function() { + checkUser(); + + global $VERSION; + Flight::render('admin/index.php', array('title' => 'Admin panel', + 'version' => $VERSION)); +}); + +Flight::route('GET /admin/oauth2_clients', function() { + + checkUser(); + global $locator; + + $clients = $locator->getOAuth2ClientDAO()->getOAuth2Clients(); + + Flight::json($clients); +}); + +Flight::route('POST /admin/oauth2_clients', function() { + + checkUser(); + global $locator; + + $client = $locator->getOAuth2ClientDAO()->createOAuth2Client(buildOAuth2ClientFromData()); + + Flight::json($client); +}); + +Flight::route('PUT /admin/oauth2_clients', function() { + + checkUser(); + global $locator; + + $client = $locator->getOAuth2ClientDAO()->updateOAuth2Client(buildOAuth2ClientFromData()); + + Flight::json($client); +}); + +Flight::route('DELETE /admin/oauth2_clients/@id', function($id) { + + checkUser(); + global $locator; + + $locator->getOAuth2ClientDAO()->deleteOAuth2Client($id); + + // Return no content + Flight::halt(204); +}); + +Flight::route('POST /admin/keypair', function() { + + checkUser(); + global $locator; + + $keyPair = $locator->getJWKSHandler()->generateKeyPair(); + Flight::json([ + "id" => $keyPair->keyId + ]); +}); + +function buildOAuth2ClientFromData() { + + $data = Flight::request()->data; + $client = new \RAP\OAuth2Client(); + + if (isset($data)) { + if (isset($data['id'])) { + $client->id = $data['id']; + } + $client->title = $data['title']; + $client->icon = $data['icon']; + $client->client = $data['client']; + $client->secret = $data['secret']; + $client->redirectUrl = $data['redirectUrl']; + $client->scope = $data['scope']; + $client->homePage = $data['homePage']; + $client->showInHome = $data['showInHome']; + } + if (isset($data['authMethods'])) { + foreach ($data['authMethods'] as $method) { + array_push($client->authMethods, $method); + } + } + + return $client; +} diff --git a/include/front-controller.php b/include/front-controller.php index 1fbabb77224c1ad6fe6b71ec73fc299ca40cd100..31ecec6e458aece9f798435de78000d1fbe9b8ee 100644 --- a/include/front-controller.php +++ b/include/front-controller.php @@ -3,7 +3,7 @@ /** * Front Controller using http://flightphp.com/ * In all these calls user session must exist, so we have to start it at the - * beginning using the startSession() function. + * beginning using the session_start() function. */ // @@ -22,140 +22,223 @@ function setCallback($callback) { * services list if a valid callback is not found */ Flight::route('/', function() { - startSession(); - $callback = setCallback(Flight::request()->data['callback']); - global $session, $callbackHandler, $BASE_PATH, $AUTHENTICATION_METHODS, $VERSION; - if ($callback === null && $session->user === null) { - Flight::render('services-list.php', array('title' => 'RAP', - 'version' => $VERSION, - 'action' => $BASE_PATH . '/')); - } else if ($callback !== null && $session->user !== null) { - $redirectURL = $callbackHandler->getLoginWithTokenURL($session->user->id, $callback); - Flight::redirect($redirectURL); - } else { - Flight::render('index.php', array('title' => 'RAP', - 'version' => $VERSION, - 'session' => $session, 'auth' => $AUTHENTICATION_METHODS)); + + session_start(); + global $locator; + + $action = Flight::request()->query['action']; + $locator->getSession()->setAction($action); + + switch ($action) { + case "oauth2client": + $clientId = $locator->getSession()->getOAuth2RequestData()->clientId; + $client = $locator->getOAuth2ClientDAO()->getOAuth2ClientByClientId($clientId); + $authPageModel = new \RAP\AuthPageModel($locator, $client); + renderMainPage($authPageModel); + break; + case "account": + $client = new \RAP\InternalClient('account'); + $client->icon = 'account-manager.png'; + $client->title = 'Account Management'; + $authPageModel = new \RAP\AuthPageModel($locator, $client); + renderMainPage($authPageModel); + break; + case "join": + $client = new \RAP\InternalClient('account'); + $client->title = 'Join identities'; + $authPageModel = new \RAP\AuthPageModel($locator, $client); + renderMainPage($authPageModel); + break; + default: + session_destroy(); + $clients = $locator->getOAuth2ClientDAO()->getOAuth2Clients(); + Flight::render('services-list.php', array('title' => 'RAP', + 'version' => $locator->getVersion(), + 'clients' => $clients, + 'action' => $locator->getBasePath() . '/')); + break; + } +}); + +function renderMainPage(RAP\AuthPageModel $authPageModel) { + global $locator; + Flight::render('main-page.php', array('title' => 'RAP', + 'version' => $locator->getVersion(), 'model' => $authPageModel)); +} + +Flight::route('GET /auth/oauth2/authorize', function() { + + session_start(); + global $locator; + + $params = [ + "client_id" => filter_input(INPUT_GET, 'client_id', FILTER_SANITIZE_STRING), + "redirect_uri" => filter_input(INPUT_GET, 'redirect_uri', FILTER_SANITIZE_STRING), + "alg" => filter_input(INPUT_GET, 'alg', FILTER_SANITIZE_STRING), + "state" => filter_input(INPUT_GET, 'state', FILTER_SANITIZE_STRING), + "scope" => filter_input(INPUT_GET, 'scope', FILTER_SANITIZE_STRING), + "nonce" => filter_input(INPUT_GET, 'nonce', FILTER_SANITIZE_STRING) + ]; + + $requestHandler = new \RAP\OAuth2RequestHandler($locator); + $requestHandler->handleAuthorizeRequest($params); + + Flight::redirect('/?action=oauth2client'); +}); + +Flight::route('POST /auth/oauth2/token', function() { + + global $locator; + + $params = [ + "grant_type" => filter_input(INPUT_POST, "grant_type", FILTER_SANITIZE_STRING), + "code" => filter_input(INPUT_POST, "code", FILTER_SANITIZE_STRING), + "redirect_uri" => filter_input(INPUT_POST, "redirect_uri", FILTER_SANITIZE_STRING), + "refresh_token" => filter_input(INPUT_POST, "refresh_token", FILTER_SANITIZE_STRING), + "scope" => filter_input(INPUT_POST, "scope", FILTER_SANITIZE_STRING) + ]; + + if ($params['grant_type'] === null) { + throw new \RAP\BadRequestException("grant_type is required"); + } + + $requestHandler = new \RAP\OAuth2RequestHandler($locator); + + switch ($params['grant_type']) { + case "authorization_code": + $token = $requestHandler->handleAccessTokenRequest($params); + break; + case "refresh_token": + $token = $requestHandler->handleRefreshTokenRequest($params); + break; + default: + throw new \RAP\BadRequestException("Unsupported grant type " . $params['grant_type']); } + + Flight::json($token); +}); + +Flight::route('POST /auth/oauth2/check_token', function() { + + global $locator; + + $requestHandler = new \RAP\OAuth2RequestHandler($locator); + $result = $requestHandler->handleCheckTokenRequest(); + + Flight::json($result); +}); + +Flight::route('GET /auth/oidc/jwks', function() { + + global $locator; + + $jwksHandler = new \RAP\JWKSHandler($locator); + $jwks = $jwksHandler->getJWKS(); + + Flight::json($jwks); }); Flight::route('GET /logout', function() { - startSession(); + session_start(); session_destroy(); Flight::redirect('/'); }); function sendAuthRedirect($url) { - startSession(); + session_start(); // reload callback from query to avoid problem with session shared between // multiple browser tabs setCallback(Flight::request()->query['callback']); Flight::redirect($url); } -Flight::route('/google', function() { - sendAuthRedirect('/auth/oauth2/google_token.php'); +Flight::route('/auth/social/google', function() { + session_start(); + global $locator; + $googleLogin = new \RAP\GoogleLogin($locator); + $redirect = $googleLogin->login(); + if ($redirect !== null) { + Flight::redirect($redirect); + } }); -Flight::route('/facebook', function() { - sendAuthRedirect('/auth/oauth2/facebook_login.php'); +Flight::route('/auth/social/facebook', function() { + session_start(); + global $locator; + $facebookLogin = new \RAP\FacebookLogin($locator); + Flight::redirect($facebookLogin->login()); }); -Flight::route('/linkedIn', function() { - sendAuthRedirect('/auth/oauth2/linkedin_login.php'); +Flight::route('/auth/social/facebook/token', function() { + session_start(); + global $locator; + $facebookLogin = new \RAP\FacebookLogin($locator); + Flight::redirect($facebookLogin->retrieveToken()); }); -Flight::route('/eduGAIN', function() { - sendAuthRedirect('/auth/saml2/aai.php'); +Flight::route('/auth/social/linkedIn', function() { + session_start(); + global $locator; + $linkedInLogin = new \RAP\LinkedInLogin($locator); + Flight::redirect($linkedInLogin->login()); }); -Flight::route('/x509', function() { - sendAuthRedirect('/auth/x509/certlogin.php'); +Flight::route('/auth/social/linkedIn/token', function() { + session_start(); + global $locator; + $linkedInLogin = new \RAP\LinkedInLogin($locator); + Flight::redirect($linkedInLogin->retrieveToken()); }); -Flight::route('/direct', function() { - global $AUTHENTICATION_METHODS; - sendAuthRedirect($AUTHENTICATION_METHODS['DirectIdP']['url']); +Flight::route('/auth/orcid', function() { + session_start(); + global $locator; + $orcidLogin = new \RAP\OrcidLogin($locator); + Flight::redirect($orcidLogin->login()); }); -/** - * Render the join confirmation page (confirmation link received by email). - */ -Flight::route('GET /confirm-join', function() { - $token = Flight::request()->query['token']; - - if ($token === null) { - http_response_code(422); - die("Token not found"); - } - - global $dao, $VERSION; - - $userIds = $dao->findJoinRequest($token); - if ($userIds === null) { - http_response_code(422); - die("Invalid token"); - } - - $applicantUser = $dao->findUserById($userIds[0]); - $targetUser = $dao->findUserById($userIds[1]); - - Flight::render('confirm-join.php', array('title' => 'RAP', - 'version' => $VERSION, - 'token' => $token, - 'applicantUser' => $applicantUser, - 'targetUser' => $targetUser)); +Flight::route('/auth/orcid/token', function() { + session_start(); + global $locator; + $code = filter_input(INPUT_GET, 'code', FILTER_SANITIZE_STRING); + $orcidLogin = new \RAP\OrcidLogin($locator); + Flight::redirect($orcidLogin->retrieveToken($code)); }); -/** - * Confirm a join and show the page containing the operation status. - */ -Flight::route('POST /confirm-join', function() { - - global $dao, $userHandler, $auditLog; - - $token = Flight::request()->data['token']; - - if ($token === null) { - http_response_code(422); - die("Token not found"); - } - - $userIds = $dao->findJoinRequest($token); - if ($userIds === null) { - http_response_code(422); - die("Invalid token"); - } - - $auditLog->info("JOIN," . $userIds[0] . "," . $userIds[1]); - - $userHandler->joinUsers($userIds[0], $userIds[1]); - $dao->deleteJoinRequest($token); +Flight::route('/auth/eduGAIN', function() { + session_start(); + global $locator; + $shibbolethLogin = new \RAP\ShibbolethLogin($locator); + Flight::redirect($shibbolethLogin->login()); +}); - // Force user to relogin to see changes to him/her identities +Flight::route('/auth/x509', function() { session_start(); - session_destroy(); + global $locator; + $x509Login = new \RAP\X509Login($locator); + $x509Login->login(); +}); - global $BASE_PATH, $VERSION; - Flight::render('join-success.php', array('title' => 'Success - RAP Join Request', - 'version' => $VERSION, - 'basePath' => $BASE_PATH)); +Flight::route('/local', function() { + global $AUTHENTICATION_METHODS; + sendAuthRedirect($AUTHENTICATION_METHODS['LocalIdP']['url']); }); /** - * Render the page for selecting th correct name and username from candidates + * Render the page for selecting the correct name and username from candidates * list during a X.509 registration. */ Flight::route('GET /x509-name-surname', function() { - startSession(); - global $session, $BASE_PATH, $VERSION; + session_start(); + global $locator, $BASE_PATH, $VERSION; + $session = $locator->getSession(); - if ($session->x509DataToRegister !== null && $session->x509DataToRegister->name === null) { + if ($session->getX509DataToRegister() !== null && $session->getX509DataToRegister()->name === null) { Flight::render('x509-name-surname.php', array('title' => 'Select name and surname', 'version' => $VERSION, - 'fullName' => $session->x509DataToRegister->fullName, - 'candidateNames' => $session->x509DataToRegister->candidateNames)); + 'fullName' => $session->getX509DataToRegister()->fullName, + 'candidateNames' => $session->getX509DataToRegister()->candidateNames)); } else { // Redirect to index header("Location: " . $BASE_PATH); @@ -165,60 +248,133 @@ Flight::route('GET /x509-name-surname', function() { /** * Complete the X.509 registration selecting the correct name and surname specified - * by the user. + * by the user (special case of composite names). */ Flight::route('POST /submit-x509-name', function() { + session_start(); + $selectedNameIndex = Flight::request()->data['selected-name']; - - error_log('index=' . $selectedNameIndex); - - startSession(); - global $session, $BASE_PATH; - - if ($session->x509DataToRegister !== null) { - $session->x509DataToRegister->selectCandidateName($selectedNameIndex); - $session->userToLogin = $session->x509DataToRegister->toUser(); - $session->x509DataToRegister = null; - $session->save(); - header("Location: " . $BASE_PATH . '/tou-check'); - die(); + + global $locator; + $session = $locator->getSession(); + + if ($session->getX509DataToRegister() !== null) { + $session->getX509DataToRegister()->selectCandidateName($selectedNameIndex); + $loginHandler = new \RAP\X509Login($locator); + $redirect = $loginHandler->afterNameSurnameSelection($session->getX509DataToRegister()); + Flight::redirect($redirect); } else { die('X.509 data not returned'); } }); +/** + * Display Term of Use acceptance page. + */ Flight::route('GET /tou-check', function() { - startSession(); - global $session, $BASE_PATH, $VERSION; + session_start(); + global $locator; - if ($session->userToLogin === null) { + if ($locator->getSession()->getUser() === null) { die("User data not retrieved."); } else { Flight::render('tou-check.php', array('title' => 'Terms of Use acceptance', - 'user' => $session->userToLogin, - 'version' => $VERSION, - 'registration_url' => $BASE_PATH . '/register')); + 'user' => $locator->getSession()->getUser(), + 'version' => $locator->getVersion(), + 'registration_url' => $locator->getBasePath() . '/register')); } }); +/** + * Stores the user data into the database after he/she accepted the Terms of Use. + */ Flight::route('GET /register', function() { - startSession(); - global $session, $userHandler, $auditLog, $callbackHandler; + session_start(); + global $locator; - if ($session->userToLogin === null) { + $user = $locator->getSession()->getUser(); + + if ($user === null) { die("User data not retrieved."); } else { + $locator->getUserHandler()->saveUser($user); + + $loginHandler = new \RAP\LoginHandler($locator, $user->identities[0]->type); + Flight::redirect($loginHandler->getAfterLoginRedirect($user)); + } +}); + +/** + * Shows Account Management page. + */ +Flight::route('GET /account', function () { + + session_start(); + global $locator; + + $user = $locator->getSession()->getUser(); + if ($user === null) { + Flight::redirect('/'); + } else { + $admin = $locator->getUserDAO()->isAdmin($user->id); + Flight::render('account-management.php', array('title' => 'RAP Account Management', + 'version' => $locator->getVersion(), 'session' => $locator->getSession(), + 'admin' => $admin, + 'contextRoot' => $locator->config->contextRoot)); + } +}); + +Flight::route('GET /token-issuer', function () { + + session_start(); + + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + $csrfToken = $_SESSION['csrf_token']; + + global $locator; + + $user = $locator->getSession()->getUser(); + $config = $locator->config->tokenIssuer; + + if ($user === null) { + Flight::redirect('/'); + } else { + $admin = $locator->getUserDAO()->isAdmin($user->id); + Flight::render('token-issuer.php', array('title' => 'RAP Token Issuer', + 'version' => $locator->getVersion(), 'session' => $locator->getSession(), + 'config' => $config, 'csrfToken' => $csrfToken, + 'contextRoot' => $locator->config->contextRoot)); + } +}); - $user = $session->userToLogin; - $userHandler->saveUser($user); +Flight::route('POST /token-issuer', function () { - $session->userToLogin = null; - $session->save(); + session_start(); + global $locator; + + if (empty($_POST['csrf_token']) || !(hash_equals($_SESSION['csrf_token'], $_POST['csrf_token']))) { + throw new \RAP\UnauthorizedException("Invalid CSRF token"); + } + if ($locator->getSession()->getUser() === null) { + throw new \RAP\UnauthorizedException("You must be registered to perform this action"); + } - $auditLog->info("LOGIN," . $user->identities[0]->type . "," . $user->id); - $callbackHandler->manageLoginRedirect($user, $session); + $postData = Flight::request()->data; + if (!isset($postData['lifespan']) || !isset($postData['audit'])) { + throw new \RAP\BadRequestException("Missing form parameter"); } + + $tokenBuilder = $locator->getTokenBuilder(); + $token = $tokenBuilder->generateNewToken($postData['lifespan'], $postData['audit']); + + header('Content-Type: text/plain'); + header("Content-disposition: attachment; filename=\"token.txt\""); + echo $token; }); + +include 'admin.php'; diff --git a/include/gui-backend.php b/include/gui-backend.php index 44e3eda9384dd4bb5aa03b63734d7cdda528a41d..9909263c61c04f2f25e5a63643a67a31b4cdbaea 100644 --- a/include/gui-backend.php +++ b/include/gui-backend.php @@ -7,50 +7,15 @@ function checkSession() { - startSession(); + session_start(); - global $session; - if ($session->user === null) { + global $locator; + if ($locator->getSession()->getUser() === null) { http_response_code(401); die("You must be registered to perform this action"); } } -/** - * This is called when an user search for other users into the join modal dialog. - */ -Flight::route('GET /user', function() { - - checkSession(); - global $session; - - $searchText = Flight::request()->query['search']; - $session->searchUser($searchText); - - $jsRes = []; - foreach ($session->userSearchResults as $searchResult) { - array_push($jsRes, $searchResult->userDisplayText); - } - - echo json_encode($jsRes); -}); - -Flight::route('POST /join', function() { - - checkSession(); - global $session, $dao, $mailSender; - - $selectedUserIndex = Flight::request()->data['selectedUserIndex']; - $selectedSearchResult = $session->userSearchResults[$selectedUserIndex]; - $targetUserId = $selectedSearchResult->getUser()->id; - - $token = RAP\Util::createNewToken(); - $dao->createJoinRequest($token, $session->user->id, $targetUserId); - $mailSender->sendJoinEmail($selectedSearchResult->getUser(), $session->user, $token); - - echo $selectedSearchResult->userDisplayText; -}); - /** * Set the primary identity based on the index of the identity clicked by the * user on the Account Management page. We MUST use the index and not directly @@ -60,15 +25,17 @@ Flight::route('POST /join', function() { Flight::route('POST /primary-identity', function() { checkSession(); - global $session, $dao; + global $locator; + + $session = $locator->getSession(); $identityIndex = intval(Flight::request()->data['index']); - $identityId = $session->user->identities[$identityIndex]->id; + $identityId = $session->getUser()->identities[$identityIndex]->id; - $dao->setPrimaryIdentity($session->user->id, $identityId); + $locator->getUserDAO()->setPrimaryIdentity($session->getUser()->id, $identityId); $session->updatePrimaryIdentity($identityId); // Following variable is used to render user-data - $user = $session->user; + $user = $session->getUser(); include 'user-data.php'; }); diff --git a/include/header.php b/include/header.php index 267bb9c942bcf496ee4e529208a2aae0d9e65ed8..2935bed635faed050cec2fdce1e1245c32f13970 100644 --- a/include/header.php +++ b/include/header.php @@ -17,7 +17,11 @@ Image Credits & Copyright: Colombari/E.Recurt </div> <div class="page-title-wrapper"> - <h1 class="text-center">Remote Authentication Portal</h1> + <h1 class="text-center">Remote Authentication Portal + <span class="circle-wrapper"> + <div class="circle">beta<br/>version!</div> + </span> + </h1> </div> </header> <div class="container"> diff --git a/include/init.php b/include/init.php index 67fa2ba5683762c9f9005fa1797331d2c873501d..de9808cdb95cc9798dc81d7ee64f9c945cc11aea 100644 --- a/include/init.php +++ b/include/init.php @@ -24,47 +24,33 @@ /** * Initialization file called by all the other pages. - * Probably some global variables should be removed from here. */ define('ROOT', dirname(dirname(__FILE__))); // Defining autoload for RAP classes -spl_autoload_register(function ($class_name) { +spl_autoload_register(function ($class) { $prefix = "RAP\\"; $len = strlen($prefix); - if (strncmp($prefix, $class_name, $len) === 0) { - $classpath = ROOT . '/classes/' . substr($class_name, $len, strlen($class_name) - $len) . '.php'; - require $classpath; + + if (strncmp($prefix, $class, $len) === 0) { + + $fileName = substr($class, $len, strlen($class) - $len); + + $classDirectories = ['/', '/login/', '/exceptions/', '/datalayer/', '/model/']; + foreach ($classDirectories as $directory) { + $classpath = ROOT . '/classes' . $directory . $fileName . '.php'; + if (file_exists($classpath)) { + require_once $classpath; + } + } } }); // Loading dependecy classes include ROOT . '/vendor/autoload.php'; -// Loading configuration -include ROOT . '/config.php'; -// Setup logging -// Monolog require timezone to be set -date_default_timezone_set("Europe/Rome"); -$log = new Monolog\Logger('mainLogger'); -$log->pushHandler(new Monolog\Handler\StreamHandler($LOG_PATH, $LOG_LEVEL)); -$auditLog = new Monolog\Logger('auditLogger'); -$auditLog->pushHandler(new Monolog\Handler\StreamHandler($AUDIT_LOG_PATH, $LOG_LEVEL)); - -switch ($DATABASE['dbtype']) { - case 'MySQL': - $dao = new RAP\MySQLDAO($DATABASE); - break; - default: - throw new Exception($DATABASE['dbtype'] . ' not supported yet'); -} - -$callbackHandler = new RAP\CallbackHandler($dao, $BASE_PATH, $CALLBACKS); -$userHandler = new RAP\UserHandler($dao, $GROUPER); -$mailSender = new RAP\MailSender($_SERVER['HTTP_HOST'], $BASE_PATH); +// Loading configuration +$config = json_decode(file_get_contents(ROOT . '/config.json')); -function startSession() { - session_start(); - global $session, $dao; - $session = RAP\SessionData::get($dao); -} +// Generating locator (global registry) +$locator = new \RAP\Locator($config); diff --git a/include/rest-web-service.php b/include/rest-web-service.php index b0ffc190795cd8c4b0d6c1cd9b7d6ff73b624c5e..ac213cf92bb7716bd1bcd1f11508744da3f69d5b 100644 --- a/include/rest-web-service.php +++ b/include/rest-web-service.php @@ -2,52 +2,23 @@ /** * REST Web Service using http://flightphp.com/ - * This Web Service should be called only by authorized applications external to - * RAP, so this directory should be password protected. - * - * Apache configuration: - * <Location "/rap-ia2/ws"> - * AuthType basic - * AuthName RAP - * AuthUserFile rap-service-passwd - * Require valid-user - * </Location> */ // $WS_PREFIX = '/ws'; -/** - * Retrieve user information from login token. - */ -Flight::route('GET ' . $WS_PREFIX . '/user-info', function() { - - global $dao; - - $token = Flight::request()->query['token']; - $userData = $dao->findLoginToken($token); - - if (is_null($userData)) { - http_response_code(404); - die("Token not found"); - } - - $dao->deleteLoginToken($token); - - header('Content-Type: text/plain'); - echo $userData; -}); - /** * Retrieve user information from user ID. */ Flight::route('GET ' . $WS_PREFIX . '/user/@userId', function($userId) { - global $dao; + global $locator; - $user = $dao->findUserById($userId); + $token = $locator->getTokenChecker()->validateToken(); + $locator->getTokenChecker()->checkScope($token, 'read:rap'); + + $user = $locator->getUserDAO()->findUserById($userId); if ($user !== null) { - header('Content-Type: application/json'); - echo json_encode($user); + Flight::json($user); } else { http_response_code(404); die("User not found"); @@ -59,19 +30,37 @@ Flight::route('GET ' . $WS_PREFIX . '/user/@userId', function($userId) { */ Flight::route('GET ' . $WS_PREFIX . '/user', function() { - global $dao; + global $locator; + + $token = $locator->getTokenChecker()->validateToken(); + $locator->getTokenChecker()->checkScope($token, 'read:rap'); $searchText = Flight::request()->query['search']; - $users = $dao->searchUser($searchText); - echo json_encode($users); + if ($searchText !== null) { + $users = $locator->getUserDAO()->searchUser($searchText); + } else { + $identifiers = Flight::request()->query['identifiers']; + if ($identifiers === null) { + throw new \RAP\BadRequestException("Missing identifiers parameters"); + } + $identifiers = explode(',', $identifiers); + $users = $locator->getUserDAO()->getUsers($identifiers); + } + + Flight::json($users); }); /** * Create new user from identity data. Return the new user encoded in JSON. + * This can be used to automatically import users without they explicitly + * register (this is done for INAF eduGAIN users reading directly from LDAP). */ Flight::route('POST ' . $WS_PREFIX . '/user', function() { - global $userHandler; + global $locator; + + $token = $locator->getTokenChecker()->validateToken(); + $locator->getTokenChecker()->checkScope($token, 'write:rap'); $postData = Flight::request()->data; @@ -96,25 +85,7 @@ Flight::route('POST ' . $WS_PREFIX . '/user', function() { $user->addIdentity($identity); - $userHandler->saveUser($user); - - echo json_encode($user); -}); - -/** - * Performs a join. - */ -Flight::route('POST ' . $WS_PREFIX . '/join', function() { - - global $userHandler; - $postData = Flight::request()->data; - - $userHandler->joinUsers($postData['user1'], $postData['user2']); - - // if the join has success, returns the remaining user id - echo $postData['user1']; -}); + $locator->getUserHandler()->saveUser($user); -Flight::route('GET ' . $WS_PREFIX . '/test', function() { - + Flight::json($user); }); diff --git a/index.php b/index.php index 754f719c6ba324aeb4dd06185284f4094c3d769c..5fd9f6db83a294a0fec9534fe1ad28f1439779be 100644 --- a/index.php +++ b/index.php @@ -28,5 +28,27 @@ include './include/front-controller.php'; include './include/gui-backend.php'; include './include/rest-web-service.php'; +// Error handling +Flight::map('error', function($ex) { + if ($ex instanceof \RAP\BadRequestException) { + http_response_code(400); + echo "Bad request: " . $ex->message; + } else if ($ex instanceof \RAP\UnauthorizedException) { + http_response_code(401); + echo "Unauthorized: " . $ex->message; + } else if ($ex instanceof \Exception) { + http_response_code(500); + if ($ex->getMessage() !== null) { + echo $ex->getMessage(); + } else { + echo $ex->getTraceAsString(); + } + } else { + http_response_code(500); + throw $ex; + } +}); + // Starting Flight framework Flight::start(); + diff --git a/js/admin.js b/js/admin.js new file mode 100644 index 0000000000000000000000000000000000000000..d07d56d68abcf2289a87b6b2596b925b886032fa --- /dev/null +++ b/js/admin.js @@ -0,0 +1,162 @@ +(function () { + + var AUTH_METHODS = ['eduGAIN', 'Google', 'Facebook', 'LinkedIn', 'X.509', 'LocalIdP']; + + var vm = new Vue({ + el: '#admin-vue', + data: { + oauth2Clients: [], + oauth2ClientToDelete: null + }, + methods: { + addNewOAuth2Client: function () { + this.$data.oauth2Clients.push(getNewClient()); + }, + editOAuth2Client: function (client) { + client.edit = true; + vm.$forceUpdate(); + }, + getAuthMethodsString: function (authMethods) { + var selectedValues = []; + for (var i = 0; i < AUTH_METHODS.length; i++) { + var method = AUTH_METHODS[i]; + if (authMethods[method] === true) { + selectedValues.push(method); + } + } + return selectedValues.join(', '); + }, + saveOAuth2Client: function (client, index) { + if (client.id === null) { + createOAuth2Client(client, index); + } else { + updateOAuth2Client(client, index); + } + }, + askConfirmDeleteOAuth2Client: function (client, index) { + vm.$data.oauth2ClientToDelete = index; + if (client.id === null) { + deleteOAuth2Client(); + } else { + $('#client-to-delete').text(client.title); + $('#confirm-delete-client-modal').modal('show'); + } + } + } + }); + + function getNewClient() { + var client = { + id: null, + title: null, + icon: null, + client: null, + secret: null, + redirectUrl: null, + scope: null, + homePage: null, + showInHome: false, + authMethods: {}, + edit: true + }; + for (var i = 0; i < AUTH_METHODS.length; i++) { + client.authMethods[AUTH_METHODS[i]] = true; + } + return client; + } + + /* Converts the model received from the server into a model + * useful for the Vue manipulation */ + function getJsModel(client) { + var jsAuthMethods = {}; + for (var i = 0; i < AUTH_METHODS.length; i++) { + var method = AUTH_METHODS[i]; + jsAuthMethods[method] = client.authMethods.includes(method); + } + client.authMethods = jsAuthMethods; + client.edit = false; + return client; + } + + /* Converts the model manipulated by Vue in the model expected by the back-end */ + function getBackendModel(client) { + var client = Vue.util.extend({}, client); + var authMethods = []; + for (var i = 0; i < AUTH_METHODS.length; i++) { + var method = AUTH_METHODS[i]; + if (client.authMethods[method] === true) { + authMethods.push(method); + } + } + client.authMethods = authMethods; + delete client.edit; + return client; + } + + function createOAuth2Client(client, index) { + showWaiting(); + $.ajax({ + url: 'admin/oauth2_clients', + method: 'POST', + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify(getBackendModel(client)) + }).then(function (data) { + vm.$data.oauth2Clients[index] = getJsModel(data); + vm.$forceUpdate(); + hideWaiting(); + }); + } + + function updateOAuth2Client(client, index) { + showWaiting(); + $.ajax({ + url: 'admin/oauth2_clients', + method: 'PUT', + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify(getBackendModel(client)) + }).then(function (data) { + vm.$data.oauth2Clients[index] = getJsModel(data); + vm.$forceUpdate(); + hideWaiting(); + }); + } + + + function deleteOAuth2Client() { + var client = vm.$data.oauth2Clients[vm.$data.oauth2ClientToDelete]; + if (client.id === null) { + vm.$data.oauth2Clients.splice(vm.$data.oauth2ClientToDelete, 1); + vm.$forceUpdate(); + } else { + showWaiting(); + $.ajax({ + url: 'admin/oauth2_clients/' + client.id, + method: 'DELETE' + }).then(function () { + vm.$data.oauth2Clients.splice(vm.$data.oauth2ClientToDelete, 1); + $('#confirm-delete-client-modal').modal('hide'); + vm.$forceUpdate(); + hideWaiting(); + }); + } + } + $(document).on('click', '#confirm-delete-client', deleteOAuth2Client); + + showWaiting(); + + $.ajax({ + url: 'admin/oauth2_clients', + method: 'GET', + dataType: 'json' + }).then(function (data) { + var clients = []; + for (var i = 0; i < data.length; i++) { + clients.push(getJsModel(data[i])); + } + vm.$data.oauth2Clients = clients; + hideWaiting(); + }); + +})(); \ No newline at end of file diff --git a/js/index.js b/js/index.js index fca834170570f0ed2ba96bf3b0f695e345a3ed2d..78e521aeeba5d0a83f871c3e23452273652fd5d5 100644 --- a/js/index.js +++ b/js/index.js @@ -4,50 +4,6 @@ // IIFE for keeping private functions and variables inside. (function ($) { - /** - * Function factory used to generate function to be executed at timeout (see below) - * @param {string} searchText - * @returns {Function} - */ - function searchUserFactory(searchText) { - return function () { - $.get('user?search=' + searchText, function (response) { - var users = JSON.parse(response); - - // Display the selector only if we have some results - $('#user-selector-group').toggleClass('hide', users.length === 0); - - // Fill the user selector - $userSelector = $('#user-selector-group select'); - $userSelector.empty(); - for (var i = 0; i < users.length; i++) { - $userSelector.append('<option value="' + i + '">' + users[i] + '</option>'); - } - - updateJoinButtonStatus(); - }); - }; - } - - /** - * Function associated to the "Send join request" button on the join modal - * dialog. - */ - function sendJoinRequest() { - - $userSelector = $('#user-selector-group select'); - var selectedUserIndex = $userSelector.val(); - - if (selectedUserIndex !== null) { - $.post('join', {selectedUserIndex: selectedUserIndex}, function (response) { - $('#search-user-modal').modal('hide'); - $infoAlert = $('#info-message-alert'); - $infoAlert.find('.info-message').text("An e-mail has been sent to " + response + " primary e-mail address."); - $infoAlert.removeClass('hide'); - }); - } - } - /** * Select the primary identity from the available identities of the user. * @param {int} index @@ -71,40 +27,12 @@ */ function loadTooltips() { $('.primary-identity-icon').tooltip(); - } - - /** - * Enable or disable the join button if there are selected users or not. - */ - function updateJoinButtonStatus() { - var selectedUserIndex = $('#user-selector-group select').val(); - $('#send-join-request-btn').prop("disabled", selectedUserIndex === null); + $('#join-btn').tooltip(); + $('#token-issuer-btn').tooltip(); } // When the document is loaded $(document).ready(function () { - - // Add keyup event handler on user search input text - var timeoutId = 0; - $(document).on('keyup', '#user-search-text', function (event) { - clearTimeout(timeoutId); - var searchUser = searchUserFactory($(event.target).val()); - // wait 500 ms without typing before doing the AJAX call - timeoutId = setTimeout(searchUser, 500); - }); - - // Add click event handler to join request button - $(document).on('click', '#send-join-request-btn', sendJoinRequest); - - // Add event handler for closing the info alert message. - // This is used instead of data-dismiss="alert" in order to maintain - // the alert inside the DOM and be able to show it again if necessary. - $(document).on('click', '#info-message-alert .close', function () { - $('#info-message-alert').addClass('hide'); - }); - - $('#search-user-modal').on('shown.bs.modal', updateJoinButtonStatus); - loadTooltips(); }); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000000000000000000000000000000000000..b5124ad913b0d19879339914a749e8c0c2ab487b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,7 @@ +<phpunit colors="true"> + <filter> + <whitelist processUncoveredFilesFromWhitelist="true"> + <directory suffix=".php">./classes/</directory> + </whitelist> + </filter> +</phpunit> diff --git a/sql/setup-database.sql b/sql/setup-database.sql index d5b4328d9e15973e4f0dbdd1a026cfca934dd987..2d46eaa0ca2af1912ff5edee9eabc30bfee173d4 100644 --- a/sql/setup-database.sql +++ b/sql/setup-database.sql @@ -1,3 +1,31 @@ +CREATE TABLE `oauth2_client` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `icon` varchar(255) DEFAULT NULL, + `client` varchar(255) NOT NULL, + `secret` varchar(255) NOT NULL, + `redirect_url` text NOT NULL, + `scope` varchar(255) DEFAULT NULL, + `home_page` varchar(255) DEFAULT NULL, + `show_in_home` tinyint(1) DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `oauth2_client_auth_methods` ( + `client_id` int NOT NULL, + `auth_method` varchar(50) NOT NULL, + PRIMARY KEY (`client_id`, `auth_method`), + FOREIGN KEY (`client_id`) REFERENCES `oauth2_client`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `oauth2_client_scope_audience_mapping` ( + `client_id` int NOT NULL, + `scope` varchar(255) NOT NULL, + `audience` text NOT NULL, + PRIMARY KEY (`client_id`, `scope`), + FOREIGN KEY (`client_id`) REFERENCES `oauth2_client`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `primary_identity` bigint(20) DEFAULT NULL, @@ -23,23 +51,42 @@ SET FOREIGN_KEY_CHECKS=0; ALTER TABLE `user` ADD FOREIGN KEY (`primary_identity`) REFERENCES `identity`(`id`); SET FOREIGN_KEY_CHECKS=1; -CREATE TABLE `login_token` ( +CREATE TABLE `access_token` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, - `token` varchar(255) NOT NULL, - `user_id` text, - `creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `user_id` varchar(255) NOT NULL, + `code_hash` varchar(255) DEFAULT NULL, + `creation_time` bigint(20) NOT NULL, + `expiration_time` bigint(20) DEFAULT NULL, + `redirect_uri` text DEFAULT NULL, + `client_id` varchar(255) DEFAULT NULL, + `scope` text DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -CREATE TABLE `join_request` ( +CREATE TABLE `refresh_token` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, - `token` varchar(255) NOT NULL, - `applicant_user_id` bigint(20) NOT NULL, - `target_user_id` bigint(20) NOT NULL, - `creation_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - FOREIGN KEY (`applicant_user_id`) REFERENCES `user`(`id`), - FOREIGN KEY (`target_user_id`) REFERENCES `user`(`id`) + `token_hash` varchar(255) NOT NULL, + `user_id` text NOT NULL, + `creation_time` BIGINT NOT NULL, + `expiration_time` BIGINT NOT NULL, + `client_id` varchar(255) NOT NULL, + `scope` text, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `rsa_keypairs` ( + `id` varchar(50) NOT NULL, + `public_key` text, + `private_key` text, + `alg` varchar(255), + `creation_time` BIGINT NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `rap_permissions` ( + `user_id` bigint NOT NULL, + `permission` varchar(255) NOT NULL, + PRIMARY KEY (`user_id`, `permission`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE EVENT login_tokens_cleanup diff --git a/tests/IdTokenBuilderTest.php b/tests/IdTokenBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2328f2c7bcc72080a617297fb20d01fea8d95c70 --- /dev/null +++ b/tests/IdTokenBuilderTest.php @@ -0,0 +1,56 @@ +<?php + +use PHPUnit\Framework\TestCase; +use \Firebase\JWT\JWT; + +final class TokenBuilderTest extends TestCase { + + public function testJWTCreation() { + + $jwksDAOStub = $this->createMock(\RAP\JWKSDAO::class); + + $locatorStub = $this->createMock(\RAP\Locator::class); + $locatorStub->method('getJWKSDAO')->willReturn($jwksDAOStub); + + $jwksHandler = new \RAP\JWKSHandler($locatorStub); + $keyPair = $jwksHandler->generateKeyPair(); + + $jwksDAOStub->method('getNewestKeyPair')->willReturn($keyPair); + + $user = new \RAP\User(); + $user->id = "user_id"; + $identity = new \RAP\Identity(\RAP\Identity::EDU_GAIN); + $identity->email = "name@inaf.it"; + $identity->name = "Name"; + $identity->surname = "Surname"; + $identity->primary = true; + $identity->institution = "INAF"; + $user->addIdentity($identity); + + $daoStub = $this->createMock(\RAP\UserDAO::class); + $locatorStub->method('getUserDAO')->willReturn($daoStub); + $daoStub->method('findUserById')->willReturn($user); + + $locatorStub->config = json_decode('{"jwtIssuer": "issuer"}'); + + $accessToken = new \RAP\AccessToken(); + $accessToken->token = "ttt"; + $accessToken->scope = ["email", "profile"]; + $accessToken->userId = "user_id"; + + $tokenBuilder = new \RAP\TokenBuilder($locatorStub); + $jwt = $tokenBuilder->getIdToken($accessToken); + + $this->assertNotNull($jwt); + + $payload = JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]); + + $this->assertEquals("issuer", $payload->iss); + $this->assertEquals($user->id, $payload->sub); + $this->assertEquals($user->getCompleteName(), $payload->name); + $this->assertEquals($identity->name, $payload->given_name); + $this->assertEquals($identity->surname, $payload->family_name); + $this->assertEquals($identity->institution, $payload->org); + } + +} diff --git a/tests/JWKSHandlerTest.php b/tests/JWKSHandlerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7901dc7645ec6c1cefdac7658e3b9fe89750ef3a --- /dev/null +++ b/tests/JWKSHandlerTest.php @@ -0,0 +1,26 @@ +<?php + +use PHPUnit\Framework\TestCase; + +final class JWKSHandlerTest extends TestCase { + + public function testKeyPairCreation(): void { + + $daoStub = $this->createMock(\RAP\JWKSDAO::class); + + $locatorStub = $this->createMock(\RAP\Locator::class); + $locatorStub->method('getJWKSDAO')->willReturn($daoStub); + + $JWKSHandler = new \RAP\JWKSHandler($locatorStub); + + $daoStub->expects($this->once()) + ->method('insertRSAKeyPair')->with($this->anything()); + + $result = $JWKSHandler->generateKeyPair(); + + $this->assertNotNull($result->keyId); + $this->assertNotNull($result->privateKey); + $this->assertNotNull($result->publicKey); + } + +} diff --git a/tests/OAuth2RequestHandlerTest.php b/tests/OAuth2RequestHandlerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e24171a449a595b89b83a976c6197098ecb7d90e --- /dev/null +++ b/tests/OAuth2RequestHandlerTest.php @@ -0,0 +1,106 @@ +<?php + +use PHPUnit\Framework\TestCase; + +final class OAuth2RequestHandlerTest extends TestCase { + + public function testBadRequestExceptionIfMissingClientId(): void { + + $this->expectException(\RAP\BadRequestException::class); + + $params = [ + "client_id" => null + ]; + + $locatorStub = $this->createMock(\RAP\Locator::class); + + $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub); + $requestHandler->handleAuthorizeRequest($params); + } + + public function testInvalidClientRedirectURI(): void { + + $this->expectException(\RAP\BadRequestException::class); + + $params = [ + "client_id" => "client_id", + "redirect_uri" => "redirect_uri", + "state" => "state", + "alg" => null, + "scope" => "email%20profile" + ]; + + $daoStub = $this->createMock(\RAP\OAuth2ClientDAO::class); + $daoStub->method('getOAuth2ClientByClientId')->willReturn(new \RAP\OAuth2Client()); + + $locatorStub = $this->createMock(\RAP\Locator::class); + $locatorStub->method('getOAuth2ClientDAO')->willReturn($daoStub); + + $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub); + $requestHandler->handleAuthorizeRequest($params); + } + + public function testExecuteOAuthStateFlow(): void { + + $params = [ + "client_id" => "client_id", + "redirect_uri" => "redirect_uri", + "state" => "state", + "alg" => null, + "nonce" => null, + "scope" => "email%20profile" + ]; + + $daoStub = $this->createMock(\RAP\OAuth2ClientDAO::class); + $client = new \RAP\OAuth2Client(); + $client->redirectUrl = "redirect_uri"; + $daoStub->method('getOAuth2ClientByClientId')->willReturn($client); + + $sessionStub = $this->createMock(\RAP\SessionData::class); + + $locatorStub = $this->createMock(\RAP\Locator::class); + $locatorStub->method('getOAuth2ClientDAO')->willReturn($daoStub); + $locatorStub->method('getSession')->willReturn($sessionStub); + + $sessionStub->expects($this->once()) + ->method('setOAuth2RequestData')->with($this->anything()); + + $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub); + $requestHandler->handleAuthorizeRequest($params); + } + + public function testHandleCheckTokenRequest(): void { + + $accessToken = new \RAP\AccessToken(); + $accessToken->clientId = 'my-client'; + $accessToken->scope = ['openid', 'email']; + $accessToken->userId = '123'; + $accessToken->expirationTime = time() + 3600; + + $tokenDaoStub = $this->createMock(\RAP\AccessTokenDAO::class); + $tokenDaoStub->method('getAccessToken')->willReturn($accessToken); + + $user = new \RAP\User(); + $user->id = '123'; + $userDaoStub = $this->createMock(\RAP\UserDAO::class); + $userDaoStub->method('findUserById')->willReturn($user); + + $tokenBuilderStub = $this->createMock(\RAP\TokenBuilder::class); + $tokenBuilderStub->method('getIdToken')->willReturn('id-token'); + + $locatorStub = $this->createMock(\RAP\Locator::class); + $locatorStub->method('getAccessTokenDAO')->willReturn($tokenDaoStub); + $locatorStub->method('getUserDAO')->willReturn($userDaoStub); + $locatorStub->method('getIdTokenBuilder')->willReturn($tokenBuilderStub); + + $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub); + + $result = $requestHandler->handleCheckTokenRequest('abc'); + + $this->assertEquals(3600, $result['exp']); + $this->assertEquals('123', $result['user_name']); + $this->assertEquals('my-client', $result['client_id']); + $this->assertEquals('id-token', $result['id_token']); + } + +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000000000000000000000000000000000000..359a5b952d49f3592571e2af081510656029298e --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +2.0.0 \ No newline at end of file diff --git a/views/account-management.php b/views/account-management.php new file mode 100644 index 0000000000000000000000000000000000000000..39712aba8c5797b11af5818f15d1232a6d90a8d6 --- /dev/null +++ b/views/account-management.php @@ -0,0 +1,52 @@ +<?php +include 'include/header.php'; +?> +<script src="js/index.js?v=<?php echo $version; ?>"></script> + +<div class="row"> + <div class="col-sm-5 col-xs-12"> + <div class="panel panel-default" id="panel-identities"> + <div class="panel-heading"> + <h3 class="panel-title">Your identities</h3> + </div> + <div class="panel-body"> + <?php + $user = $session->getUser(); + include 'include/user-data.php'; + ?> + </div> + </div> + </div> + <div class="col-sm-2"> + <div class="row"> + <div class="col-sm-12"> + <a class="btn btn-success" id="join-btn" href="<?php echo $contextRoot; ?>?action=join" title="Perform an additional login to join your identities" data-toggle="tooltip" data-placement="bottom"> + Join with another identity + </a> + </div> + </div> + <div class="row"> + <div class="col-sm-12"> + <a class="btn btn-default" id="token-issuer-btn" href="<?php echo $contextRoot; ?>/token-issuer" title="Generate tokens for CLI" data-toggle="tooltip" data-placement="bottom"> + Token issuer + </a> + </div> + </div> + <?php if ($admin) { ?> + <div class="row"> + <div class="col-sm-12"> + <a class="btn btn-default" href="<?php echo $contextRoot; ?>/admin" title="Admin panel" data-toggle="tooltip" data-placement="bottom"> + <span class="glyphicon glyphicon-wrench"></span> + Admin + </a> + </div> + </div> + <?php } ?> + </div> + <div class="col-sm-5"> + <a href="logout" class="btn btn-primary pull-right">Logout</a> + </div> +</div> + +<?php +include 'include/footer.php'; diff --git a/views/admin/index.php b/views/admin/index.php new file mode 100644 index 0000000000000000000000000000000000000000..e9d7891d34ca9bef678d6ddda5bd7249eca0a209 --- /dev/null +++ b/views/admin/index.php @@ -0,0 +1,148 @@ +<?php + +include 'include/header.php'; +?> + +<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js"></script> + +<h1 class="text-center">Admin panel</h1> + +<div id="admin-vue"> + <button class="btn btn-success pull-right" v-on:click="addNewOAuth2Client"> + <span class="glyphicon glyphicon-plus-sign"></span> Add client + </button> + <h2>OAuth2/OIDC clients</h2> + <br/> + <div class="panel panel-default" v-for="(client, index) in oauth2Clients"> + <div class="panel-heading"> + <button class="btn btn-danger pull-right" v-on:click="askConfirmDeleteOAuth2Client(client, index)"> + <span class="glyphicon glyphicon-trash"></span> Delete + </button> + <button class="btn btn-primary pull-right" v-on:click="editOAuth2Client(client)" v-if="!client.edit"> + <span class="glyphicon glyphicon-pencil"></span> Edit + </button> + <button class="btn btn-primary pull-right" v-on:click="saveOAuth2Client(client, index)" v-if="client.edit"> + <span class="glyphicon glyphicon-floppy-disk"></span> Save + </button> + <strong>{{client.title}} </strong> + </div> + <div class="panel-body"> + <div class="form-horizontal"> + <div class="form-group"> + <label class="col-sm-3 control-label" for="title">Name</label> + <div class="col-sm-9"> + <p class="form-control-static" v-if="!client.edit">{{client.title}}</p> + <input type="text" class="form-control" id="title" v-model="client.title" v-if="client.edit" /> + </div> + </div> + <div class="form-group"> + <label class="col-sm-3 control-label">Icon</label> + <div class="col-sm-9"> + <p class="form-control-static" v-if="!client.edit">{{client.icon}}</p> + <input type="text" class="form-control" id="client_icon" v-model="client.icon" v-if="client.edit" /> + </div> + </div> + <div class="form-group"> + <label class="col-sm-3 control-label" for="client_id">Client id</label> + <div class="col-sm-9"> + <p class="form-control-static" v-if="!client.edit">{{client.client}}</p> + <input type="text" class="form-control" id="client_id" v-model="client.client" v-if="client.edit" /> + </div> + </div> + <div class="form-group"> + <label class="col-sm-3 control-label" for="client_secret">Client Secret</label> + <div class="col-sm-9"> + <p class="form-control-static" v-if="!client.edit">{{client.secret}}</p> + <input type="text" class="form-control" id="client_secret" v-model="client.secret" v-if="client.edit" /> + </div> + </div> + <div class="form-group"> + <label class="col-sm-3 control-label" for="redirect_url">Redirect URL</label> + <div class="col-sm-9"> + <p class="form-control-static" v-if="!client.edit">{{client.redirectUrl}}</p> + <input type="text" class="form-control" id="redirect_url" v-model="client.redirectUrl" v-if="client.edit" /> + </div> + </div> + <div class="form-group"> + <label class="col-sm-3 control-label" for="scope">Scope</label> + <div class="col-sm-9"> + <p class="form-control-static" v-if="!client.edit">{{client.scope}}</p> + <input type="text" class="form-control" id="scope" v-model="client.scope" v-if="client.edit" /> + </div> + </div> + <div class="form-group"> + <label class="col-sm-3 control-label" for="homePage">Home Page</label> + <div class="col-sm-9"> + <p class="form-control-static" v-if="!client.edit">{{client.homePage}}</p> + <input type="text" class="form-control" id="homePage" v-model="client.homePage" v-if="client.edit" /> + </div> + </div> + <div class="form-group"> + <label class="col-sm-3 control-label" for="showInHome">Show in home</label> + <div class="col-sm-9"> + <p class="form-control-static" v-if="!client.edit"> + <span class="glyphicon glyphicon-ok" v-if="client.showInHome"></span> + </p> + <div class="checkbox" v-if="client.edit"> + <label> + <input type="checkbox" v-model="client.showInHome" /> + </label> + </div> + </div> + </div> + <div class="form-group"> + <label class="col-sm-3 control-label" for="authenticationMethods">Authentication methods</label> + <div class="col-sm-9"> + <p class="form-control-static" v-if="!client.edit">{{getAuthMethodsString(client.authMethods)}}</p> + <div v-if="client.edit"> + <div class="checkbox"> + <label> + <input type="checkbox" v-model="client.authMethods['eduGAIN']"> eduGAIN + </label> + <label> + <input type="checkbox" v-model="client.authMethods['Google']"> Google + </label> + <label> + <input type="checkbox" v-model="client.authMethods['Facebook']"> Facebook + </label> + <label> + <input type="checkbox" v-model="client.authMethods['LinkedIn']"> LinkedIn + </label> + <label> + <input type="checkbox" v-model="client.authMethods['X.509']"> X.509 + </label> + <label> + <input type="checkbox" v-model="client.authMethods['LocalIdP']"> LocalIdP + </label> + </div> + </div> + </div> + </div> + </div> + </div> + </div> +</div> + +<script src="js/admin.js"></script> + +<div class="modal fade" tabindex="-1" role="dialog" id="confirm-delete-client-modal"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> + <h4 class="modal-title">Confirm action</h4> + </div> + <div class="modal-body"> + <p>Are you sure that you want to delete the client <span id="client-to-delete"></span>?</p> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-danger" id="confirm-delete-client">Delete</button> + <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> + </div> + </div> + </div> +</div> + +<?php + +include 'include/footer.php'; diff --git a/views/confirm-join.php b/views/confirm-join.php deleted file mode 100644 index 1c22d6a683abd64fedaf0bd8a2b9b5933d8585f7..0000000000000000000000000000000000000000 --- a/views/confirm-join.php +++ /dev/null @@ -1,49 +0,0 @@ -<?php -include 'include/header.php'; -?> - -<h1 class="text-center page-title">Confirm join request</h1> - -<br/> -<div class="table-like"> - <div class="table-row"> - <div class="table-cell"> - <div class="panel panel-default"> - <div class="panel-body"> - <?php - $user = $applicantUser; - $readOnly = true; - include 'include/user-data.php'; - ?> - </div> - </div> - </div> - <div class="table-cell join-icon-cell text-success"> - <span class="glyphicon glyphicon-transfer"></span> - </div> - <div class="table-cell"> - <div class="panel panel-default"> - <div class="panel-body"> - <?php - $user = $targetUser; - $readOnly = true; - include 'include/user-data.php'; - ?> - </div> - </div> - </div> - </div> -</div> - -<div class="row"> - <div class="col-xs-12 text-center"> - <p>Pressing the following button the identities listed above will be joined.</p> - <form action="confirm-join" method="POST" onsubmit="showWaiting()"> - <input type="hidden" name="token" value="<?php echo $token; ?>" /> - <input type="submit" class="btn btn-success btn-lg" value="Join users" /> - </form> - </div> -</div> - -<?php -include 'include/footer.php'; diff --git a/views/index.php b/views/index.php deleted file mode 100644 index 774a7d85ab98a2e7bd5abbd58aa59a5bfe56972e..0000000000000000000000000000000000000000 --- a/views/index.php +++ /dev/null @@ -1,194 +0,0 @@ -<?php -include 'include/header.php'; -?> -<script src="js/index.js?v=<?php echo $version; ?>"></script> - -<?php if ($session->user === null) { ?> - <div class="row"> - <div class="col-xs-12"> - <h1 class="text-center page-title"> - <?php - if ($session->getCallbackLogo() != null) { - ?> - <img class="service-logo" src="service-logos/<?php echo $session->getCallbackLogo(); ?>" alt="" /> - <?php - } - echo $session->getCallbackTitle(); - ?> - </h1> - </div> - </div> - - <?php - if ($session->getCallbackAuth() != null) { - $authType = $session->getCallbackAuth(); - } - ?> - <div class="row" id="auth-panel"> - <div class="col-xs-12 text-center"> - <?php if (isset($auth['eduGAIN']) and - ( !isset($authType) or in_array('eduGAIN', $authType))) { ?> - <div class="home-box"> - <div class="img-wrapper"> - <a href="edugain?callback=<?php echo $session->getCallbackURL(); ?>"> - <img src="img/eduGain-200.png" alt="eduGAIN Logo" /> - </a> - </div> - Use the eduGAIN Logo to Login or Register to the RAP facility if you belong to an eduGAIN IdP. - </div> - <?php } ?> - <?php if (isset($auth['Google']) || isset($auth['Facebook']) || isset($auth['LinkedIn'])) { ?> - <div class="home-box"> - <div class="img-wrapper"> - <?php if (isset($auth['Google']) and - ( !isset($authType) or - in_array('Google', $authType))) { ?> - <a href="google?callback=<?php echo $session->getCallbackURL(); ?>" class="animated pulse"> - <?php if ((isset($auth['Facebook']) and - ( !isset($authType) or - in_array('Facebook', $authType))) or - (isset($auth['LinkedIn']) and - ( !isset($authType) or - in_array('LinkedIn', $authType)))) { ?> - <img src="img/google-60.png" alt="Google Logo" /> - </a> - <?php } else {?> - <img src="img/google-200.png" alt="Google Logo" /> - </a> - <?php } } ?> - <?php if (isset($auth['Facebook']) and - ( !isset($authType) or - in_array('Facebook', $authType))) { ?> - <a href="facebook?callback=<?php echo $session->getCallbackURL(); ?>"> - <?php if ((isset($auth['Google']) and - ( !isset($authType) or - in_array('Google', $authType))) or - (isset($auth['LinkedIn']) and - ( !isset($authType) or - in_array('LinkedIn', $authType)))) { ?> - <img src="img/facebook-60.png" alt="Facebook Logo" /> - </a> - <?php } else {?> - <img src="img/facebook-200.png" alt="Facebook Logo" /> - </a> - <?php } } ?> - <?php if (isset($auth['LinkedIn']) and - ( !isset($authType) or - in_array('LinkedIn', $authType))) { ?> - <a href="linkedin?callback=<?php echo $session->getCallbackURL(); ?>"> - <?php if ((isset($auth['Facebook']) and - ( !isset($authType) or - in_array('Facebook', $authType))) or - (isset($auth['Google']) and - ( !isset($authType) or - in_array('Google', $authType)))) { ?> - <img src="img/linkedin-60.png" alt="LinkedIn Logo" /> - </a> - <?php } else {?> - <img src="img/linkedin-200.png" alt="LinkedIn Logo" /> - </a> - <?php } } ?> - </div> - Use these Logos to Login or Register to the RAP facility with your social identity - </div> - <?php } ?> - <?php if (isset($auth['X.509']) and - ( !isset($authType) or in_array('X.509', $authType))) { ?> - <div class="home-box"> - <div class="img-wrapper"> - <a href="x509?callback=<?php echo $session->getCallbackURL(); ?>"> - <img src="img/x509-200.png" alt="X.509 Logo" /> - </a> - </div> - Use the X.509 Logo to Login with your personal certificate (IGTF and TERENA-TACAR, are allowed). - </div> - <?php } ?> - <?php if (isset($auth['DirectIdP']) and (!isset($authType) or - in_array('DirectIdP', $authType))) { ?> - <div class="home-box"> - <div class="img-wrapper"> - <a href="direct?callback=<?php echo $session->getCallbackURL(); ?>"> - <img src="<?php echo $auth['DirectIdP']['logo']; ?>" alt="<?php echo $auth['DirectIdP']['logo_alt']; ?>" /> - </a> - </div> - <?php echo $auth['DirectIdP']['description']; ?> - </div> - <?php } ?> - </div> - </div> -<?php } else { ?> - <div class="row"> - <div class="col-xs-12"> - <div class="alert alert-success hide" id="info-message-alert"> - <button type="button" class="close" aria-label="Close"><span aria-hidden="true">×</span></button> - <span class="glyphicon glyphicon-info-sign"></span> - <span class="info-message"></span> - </div> - </div> - </div> - <div class="row"> - <div class="col-sm-5 col-xs-12"> - <div class="panel panel-default" id="panel-identities"> - <div class="panel-heading"> - <h3 class="panel-title">Your identities</h3> - </div> - <div class="panel-body"> - <?php - $user = $session->user; - include 'include/user-data.php'; - ?> - </div> - </div> - </div> - <div class="col-sm-2 text-center"> - <button class="btn btn-success" type="button" data-toggle="modal" data-target="#search-user-modal"> - Join with another identity - </button> - </div> - <div class="col-sm-5"> - <a href="logout" class="btn btn-primary pull-right">Logout</a> - </div> - </div> - - <div class="modal fade" id="search-user-modal" tabindex="-1" role="dialog" aria-labelledby="search-user-modal-title"> - <div class="modal-dialog" role="document"> - <div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> - <h4 class="modal-title" id="search-user-modal-title">Search user</h4> - </div> - <div class="modal-body"> - <form class="form-horizontal"> - <div class="form-group"> - <label for="user-search-text" class="col-xs-3 control-label">Search text</label> - <div class="col-xs-9"> - <input type="text" class="form-control" id="user-search-text" placeholder="Name, surname or email..."> - </div> - </div> - <div class="form-group hide" id="user-selector-group"> - <label for="user-selector" class="col-xs-3 control-label">Select user</label> - <div class="col-xs-9"> - <select id="user-selector" class="form-control"> - - </select> - </div> - </div> - </form> - </div> - <div class="modal-footer"> - <button class="btn btn-primary" type="button" id="send-join-request-btn"> - Send join request - </button> - </div> - </div> - </div> - </div> -<?php } ?> - -<script> - $(document).on('click', '#auth-panel a', showWaiting); -</script> - -<?php -include 'include/footer.php'; - diff --git a/views/join-success.php b/views/join-success.php deleted file mode 100644 index 0587bd0cf79f5df8a4d8d6f405584d17b75d7460..0000000000000000000000000000000000000000 --- a/views/join-success.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php -include 'include/header.php'; -?> - -<h1 class="text-center page-title">Success</h1> - -<br/> -<div class="text-center"> - <p>Your identities have been joined!</p> - <br/> - <p><a href="<?php echo $basePath; ?>">Return to index</a></p> -</div> - -<?php -include 'include/footer.php'; diff --git a/views/main-page.php b/views/main-page.php new file mode 100644 index 0000000000000000000000000000000000000000..d069a7a22e94329f55313ea8b84dd0c018ead63a --- /dev/null +++ b/views/main-page.php @@ -0,0 +1,90 @@ +<?php +include 'include/header.php'; +?> +<script src="js/index.js?v=<?php echo $version; ?>"></script> + +<div class="row"> + <div class="col-xs-12"> + <h1 class="text-center page-title"> + <?php + if ($model->clientIcon != null) { + ?> + <img class="service-logo" src="<?php echo $model->clientIcon; ?>" alt="" /> + <?php + } + echo $model->clientTitle; + ?> + </h1> + </div> +</div> +<div class="row" id="auth-panel"> + <div class="col-xs-12 text-center"> + <?php if ($model->eduGAIN || $model->orcid) { ?> + <div class="home-box"> + <div class="img-wrapper"> + <?php if ($model->eduGAIN) { ?> + <a href="auth/eduGAIN"> + <img src="img/edugain-100.png" alt="eduGAIN Logo" /> + </a> + <?php } ?> + <?php if ($model->orcid) { ?> + <a href="auth/orcid"> + <img src="img/orcid-100.png" alt="ORCID Logo" /> + </a> + <?php } ?> + </div> + Use the eduGAIN or OrcID Logo to Login or Register to RAP facility with your Institutional account. + </div> + <?php } ?> + <?php if ($model->google || $model->facebook || $model->linkedIn) { ?> + <div class="home-box"> + <div class="img-wrapper"> + <?php if ($model->google) { ?> + <a href="auth/social/google" class="animated pulse"> + <img src="img/google-60.png" alt="Google Logo" /> + </a> + <?php } ?> + <?php if ($model->facebook) { ?> + <a href="auth/social/facebook"> + <img src="img/facebook-60.png" alt="Facebook Logo" /> + </a> + <?php } ?> + <?php if ($model->linkedIn) { ?> + <a href="auth/social/linkedin"> + <img src="img/linkedin-60.png" alt="LinkedIn Logo" /> + </a> + <?php } ?> + </div> + Use these Logos to Login or Register to the RAP facility with your social identity + </div> + <?php } ?> + <?php if ($model->x509) { ?> + <div class="home-box"> + <div class="img-wrapper"> + <a href="auth/x509"> + <img src="img/x509-200.png" alt="X.509 Logo" /> + </a> + </div> + Use the X.509 Logo to Login with your personal certificate (IGTF and TERENA-TACAR, are allowed). + </div> + <?php } ?> + <?php if ($model->localIdP) { ?> + <div class="home-box"> + <div class="img-wrapper"> + <a href="local"> + <img src="<?php echo $model->localIdPConfig->logo; ?>" alt="<?php echo $model->localIdPConfig->logoAlt; ?>" /> + </a> + </div> + <?php echo $model->localIdPConfig->description; ?> + </div> + <?php } ?> + </div> +</div> + +<script> + $(document).on('click', '#auth-panel a', showWaiting); +</script> + +<?php +include 'include/footer.php'; + diff --git a/views/services-list.php b/views/services-list.php index f74215ce00e839b8c09ca32526d3d97efacf8e05..5929fd778417a487bec07c55f2e284f54e1bcc40 100644 --- a/views/services-list.php +++ b/views/services-list.php @@ -1,35 +1,23 @@ <?php /** - * This page is specific for IA2. + * Shows the list of available services. */ include 'include/header.php'; ?> <div class="col-sm-offset-2 col-sm-10 services-list-wrapper"> <p>Please choose the service where you want to login:</p> <ul> + <?php + foreach ($clients as $client) { + if ($client->showInHome) { + echo '<li>'; + echo '<a href="' . $client->homePage . '">' . $client->title . '</a>'; + echo '</li>'; + } + } + ?> <li> - <form action="<?php echo $action; ?>" method="POST"> - <input name="callback" type="hidden" value="http://archives.ia2.inaf.it/tng/rest/login/rapinput" /> - <input type="submit" class="btn btn-link" value="Telescopio Nazionale Galileo (TNG) portal" /> - </form> - </li> - <li> - <form action="<?php echo $action; ?>" method="POST"> - <input name="callback" type="hidden" value="http://archives.ia2.inaf.it/aao/rest/login/rapinput" /> - <input type="submit" class="btn btn-link" value="Asiago Astrophysical Observatory portal" /> - </form> - </li> - <li> - <form action="<?php echo $action; ?>" method="POST"> - <input name="callback" type="hidden" value="<?php echo $action; ?>" /> - <input type="submit" class="btn btn-link" value="RAP Account Management" /> - </form> - </li> - <li> - <form action="<?php echo $action; ?>" method="POST"> - <input name="callback" type="hidden" value="https://sso.ia2.inaf.it/grouper" /> - <input type="submit" class="btn btn-link" value="Grouper (groups management)" /> - </form> + <a href="?action=account">RAP Account Management</a> </li> </ul> <br/> @@ -37,4 +25,3 @@ include 'include/header.php'; <?php include 'include/footer.php'; - diff --git a/views/token-issuer.php b/views/token-issuer.php new file mode 100644 index 0000000000000000000000000000000000000000..12ce18ace7b4b42f10d9d7ada6e5c064351b1d58 --- /dev/null +++ b/views/token-issuer.php @@ -0,0 +1,58 @@ +<?php +include 'include/header.php'; +?> + +<div class="row"> + <div class="col-sm-6 col-sm-offset-3"> + <p>This panel can be used to generate tokens to be used from command line interfaces and desktop applications.</p> + <br/> + <div class="panel panel-default"> + <div class="panel-heading"> + <h3 class="panel-title">Token issuer</h3> + </div> + <div class="panel-body"> + <form class="form-horizontal" action="<?php echo $contextRoot . '/token-issuer'; ?>" method="post"> + <div class="form-group"> + <label for="service" class="col-sm-4 control-label">Service</label> + <div class="col-sm-8"> + <select class="form-control" id="service" name="audit"> + <?php + foreach ($config->services as $service) { + echo "<option value=\"$service->id\">$service->label</option>"; + } + ?> + </select> + </div> + </div> + <div class="form-group"> + <label for="lifespan" class="col-sm-4 control-label">Duration (hours)</label> + <div class="col-sm-8"> + <select class="form-control" id="lifespan" name="lifespan"> + <?php + foreach ($config->lifespans as $lifespan) { + echo "<option>$lifespan</option>"; + } + ?> + </select> + </div> + </div> + <div class="form-group"> + <div class="col-sm-8 col-sm-offset-4"> + <input type="submit" class="btn btn-primary" value="Download token" /> + </div> + </div> + <input type="hidden" value="<?php echo $csrfToken; ?>" name="csrf_token" /> + </form> + </div> + </div> + <br/> + <p class="text-center"> + <strong> + <a href="<?php echo $contextRoot . '/account'; ?>">Back to account manager</a> + </strong> + </p> + </div> +</div> + +<?php +include 'include/footer.php';