From 6f493c2f358bb7bfca23c9dcf8b8487dbde23793 Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Fri, 5 Mar 2021 17:14:49 +0100
Subject: [PATCH] Refactoring of login logic preparatory to autojoin

---
 classes/Locator.php                      |   4 +
 classes/UserHandler.php                  | 107 ++++++------
 classes/datalayer/UserDAO.php            |   4 +-
 classes/datalayer/mysql/MySQLUserDAO.php |  27 ++-
 classes/login/GmsClient.php              |  71 ++++++++
 classes/login/LoginHandler.php           | 211 ++++++++++++++---------
 classes/model/SessionData.php            |  10 ++
 composer.json                            |   3 +-
 css/style.css                            |   4 +
 include/front-controller.php             |  36 ++--
 tests/LoginFlowTest.php                  | 200 +++++++++++++++++++++
 tests/LoginHandlerTest.php               |  75 ++------
 version.txt                              |   2 +-
 views/confirm-join.php                   |  23 ++-
 14 files changed, 544 insertions(+), 233 deletions(-)
 create mode 100644 classes/login/GmsClient.php
 create mode 100644 tests/LoginFlowTest.php

diff --git a/classes/Locator.php b/classes/Locator.php
index e1709a2..37c2eec 100644
--- a/classes/Locator.php
+++ b/classes/Locator.php
@@ -98,6 +98,10 @@ class Locator {
         return new TokenExchanger($this);
     }
 
+    public function getGmsClient(): GmsClient {
+        return new GmsClient($this);
+    }
+
     /**
      * Retrieve the SessionData object from the $_SESSION PHP variable. Create a
      * new one if it is necessary.
diff --git a/classes/UserHandler.php b/classes/UserHandler.php
index d7d2ba8..9d8f573 100644
--- a/classes/UserHandler.php
+++ b/classes/UserHandler.php
@@ -42,7 +42,7 @@ class UserHandler {
      * new identities to it.
      * @param \RAP\User $user
      */
-    public function saveUser(User $user) {
+    public function saveUser(User $user): void {
 
         $primarySpecified = true;
 
@@ -63,6 +63,25 @@ class UserHandler {
         }
     }
 
+    /**
+     * Update user with fresh information received by IdP. Useful for keeping email address always updated.
+     */
+    public function updateIdentity(User $user, Identity $identity): void {
+
+        $savedIdentity = $user->getIdentityByTypedId($identity->typedId);
+        $savedIdentity->email = $identity->email;
+        if ($identity->name !== null) {
+            $savedIdentity->name = $identity->name;
+        }
+        if ($identity->surname !== null) {
+            $savedIdentity->surname = $identity->surname;
+        }
+        if ($identity->institution !== null) {
+            $savedIdentity->institution = $identity->institution;
+        }
+        $this->userDAO->updateIdentity($savedIdentity);
+    }
+
     public function joinUsers(User $user1, User $user2): User {
 
         $userId1 = $user1->id;
@@ -72,69 +91,47 @@ class UserHandler {
             return $user1;
         }
 
-        // Call Grouper for moving groups and privileges from one user to the other
-        if (isset($this->locator->config->gms)) {
-
-            //create cURL connection
-            $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);
-            // Setting an empty body, otherwise Content-Length will be negative and Spring will answer with 400
-            curl_setopt($conn, CURLOPT_POSTFIELDS, " "); 
-            curl_setopt($conn, CURLOPT_HTTPHEADER, ['Authorization: Bearer '
-                . $this->getJoinAccessToken($userId1, $userId2)]);
-
-            //set data to be posted
-            curl_setopt($conn, CURLOPT_POST, 1);
-
-            //perform the request
-            $response = curl_exec($conn);
-            $info = curl_getinfo($conn);
-
-            if ($info['http_code'] === 200) {
-                curl_close($conn);
-            } else {
-                //show information regarding the error
-                curl_close($conn);
-                error_log($response);
-                $httpCode = $info['http_code'];
-                if ($httpCode === 0) {
-                    throw new ServerErrorException('GMS service is unreachable');
-                }
-                throw new ServerErrorException('Error: GMS response code: ' . $httpCode);
-            }
+        if ($userId1 === null && $userId2 === null) {
+            throw new BadRequestException("Called join on two users that are both new");
+        }
+
+        if ($userId1 === null) {
+            return $this->joinNewIdentity($user2, $user1);
         }
+        if ($userId2 === null) {
+            return $this->joinNewIdentity($user1, $user2);
+        }
+
+        // Call Grouper for moving groups and privileges from one user to the other
+        $remainingUserId = $this->locator->getGmsClient()->joinGroups($userId1, $userId2);
+
+        $remainingUser = $userId1 === $remainingUserId ? $user1 : $user2;
+        $userToDelete = $userId1 === $remainingUserId ? $user2 : $user1;
 
         // Call DAO for performing join operation into the RAP database.
-        $this->userDAO->joinUsers($userId1, $userId2);
+        $this->userDAO->joinUsers($remainingUserId, $userToDelete->id);
 
-        foreach ($user2->identities as $identity) {
-            $identity->primary = false;
-            $user1->addIdentity($identity);
-        }
+        $this->moveIdentities($remainingUser, $userToDelete);
 
         // merged user
-        return $user1;
+        return $remainingUser;
     }
 
-    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'];
+    /**
+     * New identity, not yet associated with a user: simply add it to the other user.
+     */
+    private function joinNewIdentity(User $user, User $newUser): User {
+        $identity = $newUser->identities[0];
+        $user->addIdentity($identity);
+        $this->saveUser($user);
+        return $user;
+    }
 
-        return $this->locator->getTokenBuilder()->getAccessToken($accessToken, function(& $jwt) use($userId2) {
-                    $jwt['alt_sub'] = strval($userId2);
-                });
+    private function moveIdentities(User $remainingUser, User $deletedUser) {
+        foreach ($deletedUser->identities as $identity) {
+            $identity->primary = false;
+            $remainingUser->addIdentity($identity);
+        }
     }
 
 }
diff --git a/classes/datalayer/UserDAO.php b/classes/datalayer/UserDAO.php
index ea9be27..436f755 100644
--- a/classes/datalayer/UserDAO.php
+++ b/classes/datalayer/UserDAO.php
@@ -80,5 +80,7 @@ interface UserDAO {
 
     function updateIdentity(Identity $identity): void;
 
-    function findJoinableUsers($userId): array;
+    function findJoinableUsersByEmail(string $email): array;
+    
+    function findJoinableUsersByUserId(string $userId): array;
 }
diff --git a/classes/datalayer/mysql/MySQLUserDAO.php b/classes/datalayer/mysql/MySQLUserDAO.php
index a188f27..d65bc7b 100644
--- a/classes/datalayer/mysql/MySQLUserDAO.php
+++ b/classes/datalayer/mysql/MySQLUserDAO.php
@@ -301,10 +301,33 @@ class MySQLUserDAO extends BaseMySQLDAO implements UserDAO {
         $stmt->execute();
     }
 
-    function findJoinableUsers($userId): array {
-
+    function findJoinableUsersByEmail(string $email): array {
+        
         $dbh = $this->getDBHandler();
+        
+        $query = "SELECT DISTINCT(i3.user_id)\n"
+                . "FROM user u\n"
+                . "JOIN identity i ON i.user_id = u.id\n"
+                . "JOIN identity i2 ON i.user_id = i2.user_id\n"
+                . "JOIN identity i3 ON i2.email = i3.email\n"
+                . "WHERE i.email = :email";
+
+        $stmt = $dbh->prepare($query);
+        $stmt->bindParam(':email', $email);
+
+        $stmt->execute();
 
+        $results = [];
+        foreach ($stmt->fetchAll() as $row) {
+            array_push($results, $row['user_id']);
+        }
+        return $results;
+    }
+    
+    function findJoinableUsersByUserId(string $userId): array {
+
+        $dbh = $this->getDBHandler();
+        
         $query = "SELECT DISTINCT(i3.user_id)\n"
                 . "FROM user u\n"
                 . "JOIN identity i ON i.user_id = u.id\n"
diff --git a/classes/login/GmsClient.php b/classes/login/GmsClient.php
new file mode 100644
index 0000000..7efacb1
--- /dev/null
+++ b/classes/login/GmsClient.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace RAP;
+
+class GmsClient {
+
+    private $locator;
+
+    public function __construct(Locator $locator) {
+        $this->locator = $locator;
+    }
+
+    public function joinGroups(string $userId1, string $userId2): string {
+        if (!isset($this->locator->config->gms)) {
+            return $userId1;
+        }
+
+        //create cURL connection
+        $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);
+        // Setting an empty body, otherwise Content-Length will be negative and Spring will answer with 400
+        curl_setopt($conn, CURLOPT_POSTFIELDS, " ");
+        curl_setopt($conn, CURLOPT_HTTPHEADER, ['Authorization: Bearer '
+            . $this->getJoinAccessToken($userId1, $userId2)]);
+
+        //set data to be posted
+        curl_setopt($conn, CURLOPT_POST, 1);
+
+        //perform the request
+        $response = curl_exec($conn);
+        $info = curl_getinfo($conn);
+
+        if ($info['http_code'] === 200) {
+            curl_close($conn);
+        } else {
+            //show information regarding the error
+            curl_close($conn);
+            error_log($response);
+            $httpCode = $info['http_code'];
+            if ($httpCode === 0) {
+                throw new ServerErrorException('GMS service is unreachable');
+            }
+            throw new ServerErrorException('Error: GMS response code: ' . $httpCode);
+        }
+        
+        // TODO: return id extracted from GMS response
+        return $userId1;
+    }
+
+    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/login/LoginHandler.php b/classes/login/LoginHandler.php
index 79148b8..571421e 100644
--- a/classes/login/LoginHandler.php
+++ b/classes/login/LoginHandler.php
@@ -2,6 +2,9 @@
 
 namespace RAP;
 
+/**
+ * All public methods return a redirect URL.
+ */
 class LoginHandler {
 
     protected $locator;
@@ -10,41 +13,116 @@ class LoginHandler {
         $this->locator = $locator;
     }
 
-    /**
-     * Returns redirect URL.
-     */
     public function onIdentityDataReceived(Identity $identity): string {
-        
-        $this->locator->getSession()->setLoginIdentityType($identity->type);
+
+        $session = $this->locator->getSession();
+        $session->setLoginIdentityType($identity->type);
+
+        $user = $this->getUser($identity);
+
+        $joinStep = $this->checkJoinAction($session, $user);
+        if ($joinStep !== null) {
+            return $joinStep;
+        }
+
+        $session->setUser($user);
+
+        $autoJoinStep = $this->checkAutoJoin();
+        if ($autoJoinStep !== null) {
+            return $autoJoinStep;
+        }
+
+        if ($user->id === null) {
+            return $this->redirectToTOUCheck($user);
+        } else {
+            $this->locator->getUserHandler()->updateIdentity($user, $identity);
+        }
+
+        return $this->getAfterLoginRedirect();
+    }
+
+    private function getUser(Identity $identity): User {
 
         $userDao = $this->locator->getUserDAO();
-        
+
         $user = $userDao->findUserByIdentity($identity->type, $identity->typedId);
-        
+
         if ($user === null) {
-            return $this->handleNewIdentity($identity);
-        } else {
-            $this->updateUser($user, $identity);
+            // create new user with null id
+            $user = new User();
+            $user->addIdentity($identity);
         }
 
+        return $user;
+    }
+
+    private function checkJoinAction(SessionData $session, User $user): ?string {
+        if ($session->getOAuth2RequestData() === null && $session->getAction() === 'join' && $session->getUser() !== null) {
+            if ($session->getUser()->id === $user->id) {
+                // attempting to join an already joined user: go to account page without joining
+                $session->setAction('account');
+            } else {
+                return $this->showConfirmJoin($user);
+            }
+        }
+        return null;
+    }
+
+    public function confirmJoin(): string {
+
         $session = $this->locator->getSession();
-        if ($session->getOAuth2RequestData() === null && $session->getAction() === 'join' &&
-                $session->getUser() !== null && $session->getUser()->id !== $user->id) {
-            return $this->showConfirmJoin($user);
+
+        if ($session->getUserToJoin() === null) {
+            throw new BadRequestException("Unable to find user to join");
         }
 
-        return $this->getAfterLoginRedirect($user);
+        $this->joinUsers();
+
+        $autoJoinStep = $this->checkAutoJoin();
+        if ($autoJoinStep !== null) {
+            return $autoJoinStep;
+        }
+
+        return $this->getAfterLoginRedirect();
     }
 
-    protected function handleNewIdentity(Identity $identity): string {
+    private function checkAutoJoin(): ?string {
 
+        $userDao = $this->locator->getUserDAO();
         $session = $this->locator->getSession();
+        $user = $session->getUser();
 
-        if ($session->getUser() !== null && $session->getAction() === 'join') {
-            $userToJoin = $this->getNewUser($identity);
+        if ($user->id === null) {
+            $joinableUsers = $userDao->findJoinableUsersByEmail($user->identities[0]->email);
+        } else {
+            $joinableUsers = $userDao->findJoinableUsersByUserId($user->id);
+        }
+
+        if (count($joinableUsers) > 0) {
+            // select first user
+            $userToJoin = $userDao->findUserById($joinableUsers[0]);
+            $session->setAutojoin(true);
             return $this->showConfirmJoin($userToJoin);
+        }
+
+        return null;
+    }
+
+    public function rejectJoin(): string {
+
+        $session = $this->locator->getSession();
+
+        $user = $session->getUser();
+        if ($user === null) {
+            throw new \RAP\BadRequestException("Unable to find user");
+        }
+
+        $session->setJoinRejected(true);
+
+        if ($session->getUser()->id === null) {
+            return $this->redirectToTOUCheck($session->getUser()->identities[0]);
         } else {
-            return $this->redirectToTOUCheck($identity);
+            return $this->getAfterLoginRedirect();
         }
     }
 
@@ -56,94 +134,63 @@ class LoginHandler {
     /**
      * Stores the data into session and Redirect to Term of Use acceptance page.
      */
-    private function redirectToTOUCheck(Identity $identity): string {
-
-        // Create new user
-        $user = $this->getNewUser($identity);
-
-        $this->locator->getSession()->setUser($user);
-
+    private function redirectToTOUCheck(): string {
         return $this->locator->getBasePath() . '/tou-check';
     }
 
-    private function getNewUser(Identity $identity): User {
-        $user = new User();
-        $user->addIdentity($identity);
-        return $user;
-    }
-
     /**
-     * Update user with fresh information received by IdP. Useful for keeping email address always updated.
+     * Stores the user data into the database after he/she accepted the Terms of Use.
      */
-    private function updateUser(User $user, Identity $identity): void {
-        $savedIdentity = $user->getIdentityByTypedId($identity->typedId);
-        $savedIdentity->email = $identity->email;
-        if($identity->name !== null) {
-            $savedIdentity->name = $identity->name;
-        }
-        if($identity->surname !== null) {
-            $savedIdentity->surname = $identity->surname;
-        }
-        if($identity->institution !== null) {
-            $savedIdentity->institution = $identity->institution;
+    public function register(): string {
+        $user = $this->locator->getSession()->getUser();
+
+        if ($user === null) {
+            throw new BadRequestException("User data not retrieved.");
+        } else {
+            $this->locator->getUserHandler()->saveUser($user);
+            return $this->getAfterLoginRedirect();
         }
-        $this->locator->getUserDAO()->updateIdentity($savedIdentity);
     }
 
-    public function getAfterLoginRedirect(User $user): string {
+    private function joinUsers(): void {
 
         $session = $this->locator->getSession();
-        $this->locator->getAuditLogger()->info("LOGIN," . $session->getLoginIdentityType() . "," . $user->id);
+        $user = $session->getUser();
+        $userToJoin = $session->getUserToJoin();
 
-        if ($session->getOAuth2RequestData() !== null) {
-            $session->setUser($user);
-            $redirectUrl = $this->locator->getOAuth2RequestHandler()->getRedirectResponseUrl();
-            session_destroy();
-            return $redirectUrl;
+        if ($user === null) {
+            $session->setUser($userToJoin);
+        } else {
+            $joinedUser = $this->locator->getUserHandler()->joinUsers($userToJoin, $user);
+            $session->setUser($joinedUser);
         }
 
-        if ($session->getAction() !== null) {
-
-            $action = $session->getAction();
-
-            if ($action === 'join') {
-                $user = $this->joinTo($user);
-                $action = 'account';
-                $session->setAction($action);
-            }
-
-            $session->setUser($user);
-
-            if ($action === 'account' || $action === 'admin') {
-                return $this->locator->getBasePath() . '/' . $action;
-            }
+        if ($session->getAction() === 'join') {
+            $session->setAction('account');
         }
 
-        throw new \Exception("Unable to find a proper redirect");
+        $session->setUserToJoin(null);
     }
 
-    private function joinTo(User $userToJoin): User {
+    private function getAfterLoginRedirect(): string {
 
         $session = $this->locator->getSession();
-        $user = $session->getUser();
+        $this->locator->getAuditLogger()->info("LOGIN," . $session->getLoginIdentityType() . "," . $session->getUser()->id);
 
-        if ($user === null) {
-            return $userToJoin;
+        if ($session->getOAuth2RequestData() !== null) {
+            // Redirect to OAuth2 client callback URL
+            $redirectUrl = $this->locator->getOAuth2RequestHandler()->getRedirectResponseUrl();
+            session_destroy();
+            return $redirectUrl;
         }
 
-        if ($userToJoin->id === null) {
-            // New identity, not yet associated with an user: simply add it to
-            // previously logged in user.
-            $identity = $userToJoin->identities[0];
-            $user->addIdentity($identity);
-            $this->locator->getUserHandler()->saveUser($user);
-        } else if ($user->id !== $userToJoin->id) {
-            $user = $this->locator->getUserHandler()->joinUsers($user, $userToJoin);
-        }
+        $action = $session->getAction();
 
-        $session->setUserToJoin(null);
+        if ($action === 'account' || $action === 'admin') {
+            return $this->locator->getBasePath() . '/' . $action;
+        }
 
-        return $user;
+        throw new \Exception("Unable to find a proper redirect");
     }
 
 }
diff --git a/classes/model/SessionData.php b/classes/model/SessionData.php
index e1916dc..7d4d2d4 100644
--- a/classes/model/SessionData.php
+++ b/classes/model/SessionData.php
@@ -39,6 +39,7 @@ class SessionData {
     private $action;
     private $loginIdentityType;
     private $autojoin = false;
+    private $joinRejected = false;
 
     public function setUser(?User $user): void {
         $this->user = $user;
@@ -67,6 +68,15 @@ class SessionData {
         return $this->autojoin;
     }
 
+    public function setJoinRejected(bool $joinRejected): void {
+        $this->joinRejected = $joinRejected;
+        $this->save();
+    }
+
+    public function isJoinRejected(): bool {
+        return $this->joinRejected;
+    }
+
     /**
      * Used for logging the identity type chosen for the login at the end of the login process
      */
diff --git a/composer.json b/composer.json
index 41daa1a..8701629 100644
--- a/composer.json
+++ b/composer.json
@@ -10,7 +10,8 @@
         "phpmailer/phpmailer": "^6.0"
     },
     "require-dev": {
-        "phpunit/phpunit": "^8.2"
+        "phpunit/phpunit": "^8.2",
+        "phpmd/phpmd": "@stable"
     },
     "autoload": {
         "classmap": [
diff --git a/css/style.css b/css/style.css
index 124f933..a1d2319 100644
--- a/css/style.css
+++ b/css/style.css
@@ -221,3 +221,7 @@ body {
     left: 27px;
     top: 0px;
 }
+
+.inline {
+    display: inline;
+}
diff --git a/include/front-controller.php b/include/front-controller.php
index 3ef1c86..71d85d9 100644
--- a/include/front-controller.php
+++ b/include/front-controller.php
@@ -242,7 +242,7 @@ Flight::route('GET /x509-name-surname', function() {
     } else {
         // Redirect to index
         header("Location: " . $locator->getBasePath());
-        die();
+        throw new \RAP\BadRequestException();
     }
 });
 
@@ -265,7 +265,7 @@ Flight::route('POST /submit-x509-name', function() {
         $redirect = $loginHandler->afterNameSurnameSelection($session->getX509DataToRegister());
         Flight::redirect($redirect);
     } else {
-        die('X.509 data not returned');
+        throw new \RAP\BadRequestException('X.509 data not returned');
     }
 });
 
@@ -278,7 +278,7 @@ Flight::route('GET /tou-check', function() {
     global $locator;
 
     if ($locator->getSession()->getUser() === null) {
-        die("User data not retrieved.");
+        throw new \RAP\BadRequestException("User data not retrieved.");
     } else {
         Flight::render('tou-check.php', array('title' => 'Terms of Use acceptance',
             'user' => $locator->getSession()->getUser(),
@@ -294,7 +294,7 @@ Flight::route('GET /confirm-join', function() {
     global $locator;
 
     if ($locator->getSession()->getUser() === null) {
-        die("User data not retrieved.");
+        throw new \RAP\BadRequestException("User data not retrieved.");
     } else {
         Flight::render('confirm-join.php', array('title' => 'Confirm join',
             'autojoin' => $locator->getSession()->isAutojoin(),
@@ -309,14 +309,16 @@ Flight::route('POST /confirm-join', function() {
 
     session_start();
     global $locator;
+    $loginHandler = new \RAP\LoginHandler($locator);
+    Flight::redirect($loginHandler->confirmJoin());
+});
 
-    $user = $locator->getSession()->getUserToJoin();
-    if ($user === null) {
-        die("Unable to find user to join");
-    } else {
-        $loginHandler = new \RAP\LoginHandler($locator);
-        Flight::redirect($loginHandler->getAfterLoginRedirect($user));
-    }
+Flight::route('POST /reject-join', function() {
+
+    session_start();
+    global $locator;
+    $loginHandler = new \RAP\LoginHandler($locator);
+    Flight::redirect($loginHandler->rejectJoin());
 });
 
 /**
@@ -327,16 +329,8 @@ Flight::route('GET /register', function() {
     session_start();
     global $locator;
 
-    $user = $locator->getSession()->getUser();
-
-    if ($user === null) {
-        die("User data not retrieved.");
-    } else {
-        $locator->getUserHandler()->saveUser($user);
-
-        $loginHandler = new \RAP\LoginHandler($locator);
-        Flight::redirect($loginHandler->getAfterLoginRedirect($user));
-    }
+    $loginHandler = new \RAP\LoginHandler($locator);
+    $loginHandler->register();
 });
 
 /**
diff --git a/tests/LoginFlowTest.php b/tests/LoginFlowTest.php
new file mode 100644
index 0000000..80b3086
--- /dev/null
+++ b/tests/LoginFlowTest.php
@@ -0,0 +1,200 @@
+<?php
+
+use PHPUnit\Framework\TestCase;
+
+if (session_status() === PHP_SESSION_NONE) {
+    session_start();
+}
+
+/**
+ * This test has a setup similar to the LoginHandlerTest one, however SessionData and
+ * UserHandler are not mocked, so interaction between session and login handler can be
+ * tested and complete login and join flows can be simulated step by step.
+ */
+final class LoginFlowTest extends TestCase {
+
+    private $locatorStub;
+    private $userDaoStub;
+    private $oAuth2RequestHandler;
+    private $auditLogger;
+    private $gmsClientStub;
+    private $sessionData;
+    private $userHandler;
+    private $loginHandler;
+
+    public function setUp(): void {
+        $this->locatorStub = $this->createMock(\RAP\Locator::class);
+        $this->locatorStub->method('getBasePath')->willReturn('http://rap-ia2');
+
+        $this->userDaoStub = $this->createMock(\RAP\UserDAO::class);
+        $this->locatorStub->method('getUserDAO')->willReturn($this->userDaoStub);
+
+        $this->oAuth2RequestHandler = $this->createMock(\RAP\OAuth2RequestHandler::class);
+        $this->locatorStub->method('getOAuth2RequestHandler')->willReturn($this->oAuth2RequestHandler);
+
+        $this->auditLogger = $this->createMock(\Monolog\Logger::class);
+        $this->locatorStub->method('getAuditLogger')->willReturn($this->auditLogger);
+
+        $this->gmsClientStub = $this->createMock(\RAP\GmsClient::class);
+        $this->locatorStub->method('getGmsClient')->willReturn($this->gmsClientStub);
+        
+        $this->sessionData = new \RAP\SessionData();
+        $this->locatorStub->method('getSession')->willReturn($this->sessionData);
+
+        $this->userHandler = new \RAP\UserHandler($this->locatorStub);
+        $this->locatorStub->method('getUserHandler')->willReturn($this->userHandler);
+        
+        $this->loginHandler = new \RAP\LoginHandler($this->locatorStub);
+    }
+
+    public function testNewIdentityLogin(): void {
+
+        $this->sessionData->setAction('account');
+        
+        $identity = $this->getFakeIdentity1();
+
+        // verify DAO is called for checking user existence
+        $this->userDaoStub->expects($this->once())
+                ->method('findUserByIdentity')->with($identity->type, $identity->typedId);
+
+        $redirect1 = $this->loginHandler->onIdentityDataReceived($identity);
+
+        // verify ids are not set
+        $this->assertNull($this->sessionData->getUser()->id);
+        $this->assertNull($this->sessionData->getUser()->identities[0]->id);
+
+        $this->assertEquals("http://rap-ia2/tou-check", $redirect1);
+
+        $this->userDaoStub->method('createUser')->willReturn('2');
+        $this->userDaoStub->method('insertIdentity')->willReturn('3');
+
+        $redirect2 = $this->loginHandler->register();
+
+        $this->assertEquals('2', $this->sessionData->getUser()->id);
+        $this->assertEquals('3', $this->sessionData->getUser()->identities[0]->id);
+
+        $this->assertEquals("http://rap-ia2/account", $redirect2);
+    }
+
+    public function testExistingUserLogin(): void {
+
+        $this->sessionData->setAction('account');
+        
+        $user = $this->getFakeUser1();
+        $this->assertEquals('test@example.com', $user->identities[0]->email);
+
+        $this->userDaoStub->method('findUserByIdentity')->willReturn($user);
+
+        $this->userDaoStub->expects($this->once())
+                ->method('updateIdentity')->with($this->anything());
+
+        $redirect = $this->loginHandler->onIdentityDataReceived($this->getFakeIdentity1NewEmail());
+
+        // verify email update
+        $this->assertEquals('test2@example.com', $user->identities[0]->email);
+
+        $this->assertEquals('http://rap-ia2/account', $redirect);
+    }
+    
+    public function testNewIdentityAutojoin(): void {
+        
+        $this->oAuth2RequestHandler->method('getRedirectResponseUrl')->willReturn('http://redirect-url');
+        
+        $this->userDaoStub->method('findJoinableUsersByEmail')->willReturn(['1', '2']);
+        $this->userDaoStub->method('findUserById')->willReturn($this->getFakeUser1());
+        
+        $redirect1 = $this->loginHandler->onIdentityDataReceived($this->getFakeIdentity3());
+        
+        $this->assertTrue($this->sessionData->isAutojoin());
+        $this->assertEquals('http://rap-ia2/confirm-join', $redirect1);
+        $this->assertNotNull($this->sessionData->getUserToJoin());
+        $this->assertNull($this->sessionData->getUser()->id);
+        
+        $this->userDaoStub->method('findJoinableUsersByUserId')->willReturn(['2']);
+        
+        $redirect2 = $this->loginHandler->confirmJoin();
+                
+        $this->assertEquals('1', $this->sessionData->getUser()->id);
+        $this->assertEquals('http://rap-ia2/confirm-join', $redirect2);
+                
+        $this->gmsClientStub->method('joinGroups')->willReturn('2');
+        
+        $this->gmsClientStub->expects($this->once())
+                ->method('joinGroups')->with($this->anything());
+        
+        $redirect3 = $this->loginHandler->confirmJoin();
+        
+        $this->assertEquals('2', $this->sessionData->getUser()->id);
+        $this->assertEquals('http://redirect-url', $redirect3);
+    }
+
+    public function testJoinAlreadyJoinedUser(): void { // go to account page without joining
+        $user = $this->getFakeUser1();
+
+        $this->sessionData->setAction('join');
+        $this->sessionData->setUser($user);
+
+        $this->userDaoStub->method('findUserByIdentity')->willReturn($user);
+
+        $redirect = $this->loginHandler->onIdentityDataReceived($this->getFakeIdentity1());
+
+        $this->assertEquals('http://rap-ia2/account', $redirect);
+    }
+
+    private function getFakeUser1(): \RAP\User {
+
+        $user = new \RAP\User();
+        $user->id = '1';
+        $identity = new \RAP\Identity('eduGAIN');
+        $identity->email = 'test@example.com';
+        $identity->id = '4';
+        $identity->typedId = '123';
+        $user->addIdentity($identity);
+        return $user;
+    }
+    
+    private function getFakeUser2(): \RAP\User {
+
+        $user = new \RAP\User();
+        $user->id = '2';
+        $identity = new \RAP\Identity('eduGAIN');
+        $identity->email = 'test@example.com';
+        $identity->id = '6';
+        $identity->typedId = '999';
+        $user->addIdentity($identity);
+        return $user;
+    }
+
+    private function getFakeIdentity1(): \RAP\Identity {
+
+        $identity = new \RAP\Identity(\RAP\Identity::EDU_GAIN);
+        $identity->typedId = '123';
+        $identity->email = 'test@example.com';
+        return $identity;
+    }
+
+    private function getFakeIdentity1NewEmail(): \RAP\Identity {
+
+        $identity = new \RAP\Identity(\RAP\Identity::EDU_GAIN);
+        $identity->typedId = '123';
+        $identity->email = 'test2@example.com';
+        return $identity;
+    }
+
+    private function getFakeIdentity2(): \RAP\Identity {
+
+        $identity = new \RAP\Identity(\RAP\Identity::EDU_GAIN);
+        $identity->typedId = '456';
+        $identity->email = 'test3@example.com';
+        return $identity;
+    }
+
+    private function getFakeIdentity3(): \RAP\Identity {
+
+        $identity = new \RAP\Identity(\RAP\Identity::GOOGLE);
+        $identity->typedId = '789';
+        $identity->email = 'test@example.com';
+        return $identity;
+    }
+
+}
diff --git a/tests/LoginHandlerTest.php b/tests/LoginHandlerTest.php
index e8cf6f1..3540aa4 100644
--- a/tests/LoginHandlerTest.php
+++ b/tests/LoginHandlerTest.php
@@ -2,13 +2,16 @@
 
 use PHPUnit\Framework\TestCase;
 
-session_start();
+if (session_status() === PHP_SESSION_NONE) {
+    session_start();
+}
 
 final class LoginHandlerTest extends TestCase {
 
     private $locatorStub;
     private $userDaoStub;
     private $sessionStub;
+    private $userHandlerStub;
     private $oAuth2RequestHandler;
     private $auditLogger;
     private $loginHandler;
@@ -23,39 +26,30 @@ final class LoginHandlerTest extends TestCase {
         $this->sessionStub = $this->createMock(\RAP\SessionData::class);
         $this->locatorStub->method('getSession')->willReturn($this->sessionStub);
 
+        $this->userHandlerStub = $this->createMock(\RAP\UserHandler::class);
+        $this->locatorStub->method('getUserHandler')->willReturn($this->userHandlerStub);
+
         $this->oAuth2RequestHandler = $this->createMock(\RAP\OAuth2RequestHandler::class);
         $this->locatorStub->method('getOAuth2RequestHandler')->willReturn($this->oAuth2RequestHandler);
 
         $this->auditLogger = $this->createMock(\Monolog\Logger::class);
         $this->locatorStub->method('getAuditLogger')->willReturn($this->auditLogger);
 
-        $this->loginHandler = new \RAP\LoginHandler($this->locatorStub, 'eduGAIN');
-    }
-
-    public function testTOUCheck(): void {
-        $redirect = $this->loginHandler->onIdentityDataReceived($this->getFakeIdentity1());
-
-        $this->assertEquals('http://rap-ia2/tou-check', $redirect);
+        $this->loginHandler = new \RAP\LoginHandler($this->locatorStub);
     }
 
     public function testExistingUserLogin(): void {
 
         $user = $this->getFakeUser();
-        $this->assertEquals('test@example.com', $user->identities[0]->email);
 
         $this->userDaoStub->method('findUserByIdentity')->willReturn($user);
 
         $this->oAuth2RequestHandler->method('getRedirectResponseUrl')->willReturn('http://redirect-url');
 
         $this->sessionStub->method('getOAuth2RequestData')->willReturn(new \RAP\OAuth2RequestData());
+        $this->sessionStub->method('getUser')->willReturn($user);
 
-        $this->userDaoStub->expects($this->once())
-                ->method('updateIdentity')->with($this->anything());
-
-        $redirect = $this->loginHandler->onIdentityDataReceived($this->getFakeIdentity1NewEmail());
-
-        // verify update
-        $this->assertEquals('test2@example.com', $user->identities[0]->email);
+        $redirect = $this->loginHandler->onIdentityDataReceived($this->getFakeIdentity1());
 
         $this->assertEquals('http://redirect-url', $redirect);
     }
@@ -91,47 +85,6 @@ final class LoginHandlerTest extends TestCase {
         $this->assertEquals('http://rap-ia2/confirm-join', $redirect);
     }
 
-    public function testJoinExistingUser(): void {
-
-        $user = $this->getFakeUser();
-        $this->sessionStub->method('getUser')->willReturn($user);
-        $this->sessionStub->method('getAction')->willReturn('join');
-
-        $userToJoin = $this->getFakeUser();
-        $userToJoin->id = '456';
-
-        $redirect = $this->loginHandler->getAfterLoginRedirect($userToJoin);
-
-        $this->assertEquals('http://rap-ia2/account', $redirect);
-    }
-
-    public function testJoinNewIdentity(): void {
-
-        $user = $this->getFakeUser();
-        $this->sessionStub->method('getUser')->willReturn($user);
-        $this->sessionStub->method('getAction')->willReturn('join');
-
-        $userToJoin = $this->getFakeUser();
-        // new identity
-        $userToJoin->id = null;
-
-        $redirect = $this->loginHandler->getAfterLoginRedirect($userToJoin);
-
-        $this->assertEquals('http://rap-ia2/account', $redirect);
-    }
-
-    public function testJoinAlreadyJoinedUser(): void { // go to account page without joining
-        $user = $this->getFakeUser();
-
-        $this->sessionStub->method('getAction')->willReturn('join');
-
-        $this->userDaoStub->method('findUserByIdentity')->willReturn($user);
-
-        $redirect = $this->loginHandler->onIdentityDataReceived($this->getFakeIdentity1());
-
-        $this->assertEquals('http://rap-ia2/account', $redirect);
-    }
-
     private function getFakeUser(): \RAP\User {
 
         $user = new \RAP\User();
@@ -152,14 +105,6 @@ final class LoginHandlerTest extends TestCase {
         return $identity;
     }
 
-    private function getFakeIdentity1NewEmail(): \RAP\Identity {
-
-        $identity = new \RAP\Identity(\RAP\Identity::EDU_GAIN);
-        $identity->typedId = '123';
-        $identity->email = 'test2@example.com';
-        return $identity;
-    }
-
     private function getFakeIdentity2(): \RAP\Identity {
 
         $identity = new \RAP\Identity(\RAP\Identity::EDU_GAIN);
diff --git a/version.txt b/version.txt
index e9307ca..50ffc5a 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-2.0.2
+2.0.3
diff --git a/views/confirm-join.php b/views/confirm-join.php
index 6a4e12f..efab33d 100644
--- a/views/confirm-join.php
+++ b/views/confirm-join.php
@@ -6,9 +6,16 @@ include 'include/header.php';
 <div class="text-center">
     <?php if ($autojoin) { ?>
         <h3>Multiple accounts detected</h3><br/>
-        <p>The system found multiple accounts associated with the same e-mail address. We suggest you to join them, so that
-            they will be seen as a single user. If you prefer to keep these accounts separated you can click on "Reject join" button.
-        </p>
+        <div class="alert alert-info">
+            <p>
+                <span class="glyphicon glyphicon-info-sign"></span>
+                The system found multiple accounts associated with the same e-mail address. We <strong>suggest</strong> you to join them, so that
+                they will be seen as a single user.<br/><br/>
+                If you prefer to keep these accounts separated you can click on "Reject join" button.
+                If you reject the join we will not show this message again for the same account pair, however you can still join the accounts manually
+                (see the <a href="https://sso.ia2.inaf.it/home/index.php?lang=en" target="blank_">Help Page</a> for more information).                
+            </p>
+        </div>
     <?php } else { ?>
         <h3>Following identities will be joined:</h3><br/>
     <?php } ?>
@@ -45,10 +52,16 @@ include 'include/header.php';
 
 <div class="text-center">
     <br/>
-    <form action="confirm-join" method="POST">
+    <form action="confirm-join" method="POST" class="inline">
         <input type="submit" value="Confirm join" class="btn btn-success btn-lg" />
     </form>
-    <br/><br/><br/>
+    <?php if ($autojoin) { ?>
+        &nbsp;
+        <form action="reject-join" method="POST" class="inline">
+            <input type="submit" value="Reject join" class="btn btn-danger btn-lg" />
+        </form></form>
+<?php } ?>
+<br/><br/><br/>
 </div>
 
 <?php
-- 
GitLab