diff --git a/TODO b/TODO index 0e4659a..8e014c9 100644 --- a/TODO +++ b/TODO @@ -65,13 +65,14 @@ - [v] IpAttempts - [v] Util - [v] PasswordValidator +- [v] User - [ ] Mailer - [ ] MailTemplateGenerator - [ ] MailTemplates - [ ] Server -- [ ] SolidNotifications -- [ ] SolidPubSub - [ ] StorageServer -- [ ] User +- [-] Session +- [-] SolidNotifications +- [-] SolidPubSub - [-] Middleware - [-] Db diff --git a/docker/solid.conf b/docker/solid.conf index a1634a3..df551b3 100644 --- a/docker/solid.conf +++ b/docker/solid.conf @@ -20,7 +20,7 @@ ServerName identity.solid.local ServerAlias *.solid.local - DocumentRoot /opt/solid/www/profile + DocumentRoot /opt/solid/www/user SSLEngine on SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem @@ -39,7 +39,7 @@ RewriteCond %{HTTP_HOST} ^id-([a-zA-Z0-9-]+)\.solid\.local$ [NC] # Example rewrite rule based on the first part of the hostname # This will redirect to /subdomain-content/first_part_of_hostname - RewriteRule ^(.+)$ index.php [QSA,L] + RewriteRule ^(.+)$ profile.php [QSA,L] # Extract the first part of the subdomain (before the first dot) RewriteCond %{HTTP_HOST} ^storage-([a-zA-Z0-9-]+)\.solid\.local$ [NC] diff --git a/lib/Session.php b/lib/Session.php new file mode 100644 index 0000000..4711287 --- /dev/null +++ b/lib/Session.php @@ -0,0 +1,20 @@ + 24*60*60 // 1 day + ]); + $_SESSION['username'] = $username; + } + + public static function getLoggedInUser() { + session_start(); + if (!isset($_SESSION['username'])) { + return false; + } + return $_SESSION['username']; + } + } diff --git a/lib/User.php b/lib/User.php index 22a2527..2b108d4 100644 --- a/lib/User.php +++ b/lib/User.php @@ -178,7 +178,7 @@ public static function getStorage($userId) { public static function setStorage($userId, $storageUrl) { Db::connect(); $query = Db::$pdo->prepare( - 'INSERT OR REPLACE INTO storage VALUES(:userId, :storageUrl)' + 'INSERT OR REPLACE INTO userStorage VALUES(:userId, :storageUrl)' ); $query->execute([ ':userId' => $userId, @@ -187,6 +187,9 @@ public static function setStorage($userId, $storageUrl) { } public static function getUser($email) { + if (!isset($email)) { + return false; + } Db::connect(); $query = Db::$pdo->prepare( 'SELECT user_id, data FROM users WHERE email=:email' @@ -247,24 +250,12 @@ public static function checkPassword($email, $password) { $result = $query->fetchAll(); if (sizeof($result) === 1) { if (password_verify($password, $result[0]['password'])) { - session_start([ - 'cookie_lifetime' => 24*60*60 // 1 day - ]); - $_SESSION['username'] = $email; return true; } } return false; } - public static function getLoggedInUser() { - session_start(); - if (!isset($_SESSION['username'])) { - return false; - } - return self::getUser($_SESSION['username']); - } - public static function userIdExists($userId) { Db::connect(); $query = Db::$pdo->prepare( diff --git a/tests/phpunit/UserTest.php b/tests/phpunit/UserTest.php new file mode 100644 index 0000000..b61faeb --- /dev/null +++ b/tests/phpunit/UserTest.php @@ -0,0 +1,354 @@ +add(new \DateInterval('P120M')); + $statements = [ + 'DROP TABLE IF EXISTS allowedClients', + 'DROP TABLE IF EXISTS userStorage', + 'DROP TABLE IF EXISTS verify', + 'DROP TABLE IF EXISTS users', + 'CREATE TABLE IF NOT EXISTS allowedClients ( + userId VARCHAR(255) NOT NULL PRIMARY KEY, + clientId VARCHAR(255) NOT NULL + )', + 'CREATE TABLE IF NOT EXISTS userStorage ( + userId VARCHAR(255) NOT NULL PRIMARY KEY, + storageUrl VARCHAR(255) NOT NULL + )', + 'CREATE TABLE IF NOT EXISTS verify ( + code VARCHAR(255) NOT NULL PRIMARY KEY, + data TEXT NOT NULL + )', + 'CREATE TABLE IF NOT EXISTS users ( + user_id VARCHAR(255) NOT NULL PRIMARY KEY, + email TEXT NOT NULL, + password TEXT NOT NULL, + data TEXT + )', + 'INSERT INTO verify VALUES("test1", \'{"expires": 0, "hello": "world", "code": "test1"}\')', + 'INSERT INTO verify VALUES("test2", \'{"expires": ' . $futureTimestamp->getTimestamp() . ', "hello": "world", "code": "test2"}\')' + ]; + + Db::connect(); + try { + // create tables + foreach($statements as $statement){ + Db::$pdo->exec($statement); + } + } catch(\PDOException $e) { + echo $e->getMessage(); + } + } + + public function testSaveVerifyToken() { + $beforeExpires = new \DateTime(); + $beforeExpires->add(new \DateInterval('PT29M')); + + $afterExpires = new \DateTime(); + $afterExpires->add(new \DateInterval('PT31M')); + $token = User::saveVerifyToken("verify", [ + "hello" => "world" + ]); + $this->assertTrue($token['expires'] > $beforeExpires->getTimestamp()); + $this->assertTrue($token['expires'] < $afterExpires->getTimestamp()); + + $storedToken = User::getVerifyToken($token['code']); + $this->assertEquals($storedToken['hello'], "world"); + } + + public function testSavePasswordResetToken() { + $beforeExpires = new \DateTime(); + $beforeExpires->add(new \DateInterval('PT29M')); + + $afterExpires = new \DateTime(); + $afterExpires->add(new \DateInterval('PT31M')); + $token = User::saveVerifyToken("verify", [ + "hello" => "world" + ]); + $this->assertTrue($token['expires'] > $beforeExpires->getTimestamp()); + $this->assertTrue($token['expires'] < $afterExpires->getTimestamp()); + + $storedToken = User::getVerifyToken($token['code']); + $this->assertEquals($storedToken['hello'], "world"); + } + + public function testSaveAccountDeleteToken() { + $beforeExpires = new \DateTime(); + $beforeExpires->add(new \DateInterval('PT29M')); + + $afterExpires = new \DateTime(); + $afterExpires->add(new \DateInterval('PT31M')); + $token = User::saveVerifyToken("verify", [ + "hello" => "world" + ]); + $this->assertTrue($token['expires'] > $beforeExpires->getTimestamp()); + $this->assertTrue($token['expires'] < $afterExpires->getTimestamp()); + + $storedToken = User::getVerifyToken($token['code']); + $this->assertEquals($storedToken['hello'], "world"); + } + + public function testExpiredToken() { + $token = User::getVerifyToken("test1"); + $this->assertFalse($token); + } + + public function testNonExpiredToken() { + $token = User::getVerifyToken("test2"); + $this->assertEquals($token['hello'], "world"); + } + + public function testCreateUser() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + $this->assertEquals($createdUser['email'], "user@example.com"); + $this->assertTrue(isset($createdUser['webId'])); + $this->assertTrue(isset($createdUser['userId'])); + $this->assertTrue(strlen($createdUser['userId']) === 32); + + $canLogIn = User::checkPassword($newUser['email'], $newUser['password']); + $this->assertTrue($canLogIn); + } + + public function testGetUser() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user2@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + + $userByEmail = User::getUser($newUser['email']); + $this->assertEquals($userByEmail['webId'], $createdUser['webId']); + $this->assertEquals($userByEmail['hello'], 'world'); + $this->assertTrue(isset($userByEmail['allowedClients'])); + $this->assertEquals($userByEmail['issuer'], "https://solid.example.com"); + } + + public function testGetUserById() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user3@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + + $userById = User::getUserById($createdUser['userId']); + $this->assertEquals($userById['webId'], $createdUser['webId']); + $this->assertEquals($userById['hello'], 'world'); + $this->assertTrue(isset($userById['allowedClients'])); + $this->assertEquals($userById['issuer'], "https://solid.example.com"); + } + + public function testSetPasswordNonExistingUser() { + $result = User::setUserPassword("not_here@example.com", "hello123!@#ABC"); + $this->assertFalse($result); + } + + public function testSetWeakPassword() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user4@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + $result = User::setUserPassword($newUser['email'], "a"); + $this->assertFalse($result); + } + + public function testLogin() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user5@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + + $canLogIn = User::checkPassword($newUser['email'], $newUser['password']); + $this->assertTrue($canLogIn); + } + + public function testLoginFailed() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user6@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + + $canLogIn = User::checkPassword($newUser['email'], "something else"); + $this->assertFalse($canLogIn); + } + + public function testSetStrongPassword() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user7@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + + $result = User::setUserPassword($newUser['email'], "this is a strong password because it is long enough"); + $this->assertTrue($result); + } + + public function testLoginAfterChange() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user8@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + $canLogIn = User::checkPassword($newUser['email'], $newUser['password']); + $this->assertTrue($canLogIn); + + $newPassword = "this is a strong password because it is long enough"; + $result = User::setUserPassword($newUser['email'], $newPassword); + $this->assertTrue($result); + + $canLogIn = User::checkPassword($newUser['email'], "something else"); + $this->assertFalse($canLogIn); + + $canLogIn = User::checkPassword($newUser['email'], $newUser['password']); + $this->assertFalse($canLogIn); + + $canLogIn = User::checkPassword($newUser['email'], $newPassword); + $this->assertTrue($canLogIn); + } + + public function testUserStorage() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user9@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + $storageUrl = "https://storage.example.com"; + User::setStorage($createdUser['userId'], $storageUrl); + + $savedStorage = User::getStorage($createdUser['userId']); + + $this->assertTrue(in_array($storageUrl, $savedStorage)); + + $user = User::getUser($newUser['email']); + $this->assertTrue(in_array($storageUrl, $user['storage'])); + } + + public function testUserExistsById() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user10@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + + $userExists = User::userIdExists($createdUser['userId']); + $this->assertTrue($userExists); + } + + public function testUserDoesNotExistsById() { + $userExists = User::userIdExists("foo"); + $this->assertFalse($userExists); + } + + public function testUserExistsByEmail() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user11@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + + $userExists = User::userEmailExists($newUser['email']); + $this->assertTrue($userExists); + } + + public function testUserDoesNotExistsByEmail() { + $userExists = User::userEmailExists("foo@example.com"); + $this->assertFalse($userExists); + } + + public function testAllowClientForUser() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user11@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + + $clientId = "12345"; + $result = User::allowClientForUser($clientId, $createdUser['userId']); + $this->assertTrue($result); + + $allowedClients = User::getAllowedClients($createdUser['userId']); + $this->assertTrue(in_array($clientId, $allowedClients)); + + $user = User::getUser($newUser['email']); + $this->assertTrue(in_array($clientId, $user['allowedClients'])); + } + + public function testDeleteAccount() { + $newUser = [ + "password" => "hello123!@#ABC", + "email" => "user11@example.com", + "hello" => "world" + ]; + $createdUser = User::createUser($newUser); + $clientId = "12345"; + $result = User::allowClientForUser($clientId, $createdUser['userId']); + + $this->assertTrue(User::userIdExists($createdUser['userId'])); + $this->assertTrue(User::userEmailExists($newUser['email'])); + $allowedClients = User::getAllowedClients($createdUser['userId']); + $this->assertTrue(in_array($clientId, $allowedClients)); + + User::deleteAccount($newUser['email']); + + $this->assertFalse(User::userIdExists($createdUser['userId'])); + $this->assertFalse(User::userEmailExists($newUser['email'])); + $allowedClients = User::getAllowedClients($createdUser['userId']); + $this->assertEmpty($allowedClients); + } + + public function testCleanup() { + // empty the verify table first so we have dependable numbers + $query = Db::$pdo->prepare('DELETE FROM verify WHERE NOT code=""'); + $query->execute(); + + $token1 = User::saveVerifyToken("verify", [ + "hello" => "world", + "expires" => time() - 10 + ]); + $token2 = User::saveVerifyToken("verify", [ + "hello" => "world", + "expires" => time() - 10 + ]); + $query = Db::$pdo->prepare('SELECT count(*) AS count FROM verify'); + $query->execute(); + $result = $query->fetchAll(); + $beforeCleanup = $result[0]['count']; + $this->assertEquals(2, $beforeCleanup); + + User::cleanupTokens(); + $query = Db::$pdo->prepare('SELECT count(*) AS count FROM verify'); + $query->execute(); + $result = $query->fetchAll(); + $afterCleanup = $result[0]['count']; + + $this->assertEquals(0, $afterCleanup); + } +} diff --git a/tests/phpunit/test-config.php b/tests/phpunit/test-config.php index c917c6c..e634ab1 100644 --- a/tests/phpunit/test-config.php +++ b/tests/phpunit/test-config.php @@ -3,3 +3,7 @@ const DBPATH = ":memory:"; const TRUSTED_IPS = ['127.0.0.100']; +const BANNED_PASSWORDS = []; +const MINIMUM_PASSWORD_ENTROPY = 10; +const BASEDOMAIN = "solid.example.com"; +const BASEURL = "https://solid.example.com"; \ No newline at end of file diff --git a/www/idp/index.php b/www/idp/index.php index 4112334..10ba492 100644 --- a/www/idp/index.php +++ b/www/idp/index.php @@ -10,6 +10,7 @@ use Pdsinterop\PhpSolid\Server; use Pdsinterop\PhpSolid\ClientRegistration; use Pdsinterop\PhpSolid\User; + use Pdsinterop\PhpSolid\Session; use Pdsinterop\PhpSolid\Mailer; use Pdsinterop\PhpSolid\IpAttempts; use Pdsinterop\PhpSolid\JtiStore; @@ -36,7 +37,7 @@ break; case "/authorize": case "/authorize/": - $user = User::getLoggedInUser(); + $user = User::getUser(Session::getLoggedInUser()); if (!$user) { header("Location: /login/?redirect_uri=" . urlencode($_SERVER['REQUEST_URI'])); exit(); @@ -73,7 +74,9 @@ } if (!isset($getVars["redirect_uri"])) { - $getVars['redirect_uri'] = $token->claims()->get("redirect_uri"); + if (isset($token)) { + $getVars['redirect_uri'] = $token->claims()->get("redirect_uri"); + } } } @@ -124,7 +127,7 @@ break; case "/dashboard": case "/dashboard/": - $user = User::getLoggedInUser(); + $user = User::getUser(Session::getLoggedInUser()); if (!$user) { header("Location: /login/"); exit(); @@ -133,7 +136,7 @@ break; case "/logout": case "/logout/": - $user = User::getLoggedInUser(); + $user = User::getUser(Session::getLoggedInUser()); if ($user) { session_destroy(); } @@ -161,7 +164,7 @@ break; case "/sharing": case "/sharing/": - $user = User::getLoggedInUser(); + $user = User::getUser(Session::getLoggedInUser()); if (!$user) { header("Location: /login/"); exit(); @@ -311,6 +314,7 @@ exit(); } if (User::checkPassword($_POST['username'], $_POST['password'])) { + Session::start($_POST['username']); if (!isset($_POST['redirect_uri']) || $_POST['redirect_uri'] === '') { header("Location: /dashboard/"); exit(); @@ -366,7 +370,7 @@ break; case "/api/sharing": case "/api/sharing/": - $user = User::getLoggedInUser(); + $user = User::getUser(Session::getLoggedInUser()); if (!$user) { header("HTTP/1.1 400 Bad request"); } else { diff --git a/www/profile/index.php b/www/user/profile.php similarity index 89% rename from www/profile/index.php rename to www/user/profile.php index f3d1a0c..c18e534 100644 --- a/www/profile/index.php +++ b/www/user/profile.php @@ -23,9 +23,12 @@ $userId = preg_replace("/^id-/", "", $idPart); $user = User::getUserById($userId); - if (!isset($user['storage'])) { + if (!isset($user['storage']) || !$user['storage']) { $user['storage'] = "https://storage-" . $userId . "." . BASEDOMAIN . "/"; } + if (is_array($user['storage'])) { // empty array is already handled + $user['storage'] = array_values($user['storage'])[0]; // FIXME: Handle multiple storage pods + } if (!isset($user['issuer'])) { $user['issuer'] = BASEURL; } diff --git a/www/profile/storage.php b/www/user/storage.php similarity index 100% rename from www/profile/storage.php rename to www/user/storage.php