diff --git a/lib/Mailer.php b/lib/Mailer.php index b38e032..7d50853 100644 --- a/lib/Mailer.php +++ b/lib/Mailer.php @@ -14,13 +14,13 @@ public static function getMailer() { // Settings $mailer->IsSMTP(); $mailer->CharSet = 'UTF-8'; - $mailer->Host = MAILER['host']; - $mailer->SMTPDebug = 0; - $mailer->Port = MAILER['port']; + $mailer->Host = MAILER['host']; + $mailer->SMTPDebug = 0; + $mailer->Port = MAILER['port']; if (isset(MAILER['user'])) { - $mailer->SMTPAuth = true; - $mailer->Username = MAILER['user']; - $mailer->Password = MAILER['password']; + $mailer->SMTPAuth = true; + $mailer->Username = MAILER['user']; + $mailer->Password = MAILER['password']; } $mailer->isHTML(true); $mailer->setFrom(MAILER['from']); @@ -50,10 +50,10 @@ public static function sendAccountCreated($data) { $mailer->addAddress($mailTo); $mailer->Subject = $mailSubject; - $mailer->Body = $mailHtmlBody; + $mailer->Body = $mailHtmlBody; $mailer->AltBody = $mailPlainBody; - $mailer->send(); + return $mailer->send(); } public static function sendVerify($data) { @@ -79,10 +79,10 @@ public static function sendVerify($data) { $mailer->addAddress($mailTo); $mailer->Subject = $mailSubject; - $mailer->Body = $mailHtmlBody; + $mailer->Body = $mailHtmlBody; $mailer->AltBody = $mailPlainBody; - $mailer->send(); + return $mailer->send(); } public static function sendResetPassword($data) { @@ -107,10 +107,10 @@ public static function sendResetPassword($data) { $mailer->addAddress($mailTo); $mailer->Subject = $mailSubject; - $mailer->Body = $mailHtmlBody; + $mailer->Body = $mailHtmlBody; $mailer->AltBody = $mailPlainBody; - $mailer->send(); + return $mailer->send(); } public static function sendDeleteAccount($data) { @@ -135,9 +135,9 @@ public static function sendDeleteAccount($data) { $mailer->addAddress($mailTo); $mailer->Subject = $mailSubject; - $mailer->Body = $mailHtmlBody; + $mailer->Body = $mailHtmlBody; $mailer->AltBody = $mailPlainBody; - $mailer->send(); + return $mailer->send(); } } diff --git a/lib/PasswordValidator.php b/lib/PasswordValidator.php index f5d98ec..38704f4 100644 --- a/lib/PasswordValidator.php +++ b/lib/PasswordValidator.php @@ -1,114 +1,114 @@ ?@[\]^_{|}~'; - private static string $lowercaseCharacters = 'abcdefghijklmnopqrstuvwxyz'; - private static string $uppercaseCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - private static string $numbers = '0123456789'; + private static string $specialCharacters = ' !"#$%&\'()*+,-./:;<=>?@[\]^_{|}~'; + private static string $lowercaseCharacters = 'abcdefghijklmnopqrstuvwxyz'; + private static string $uppercaseCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + private static string $numbers = '0123456789'; - /** - * The maximum number of times the same character can appear in the password - * @var int - */ - private static int $maxOccurrences = 2; + /** + * The maximum number of times the same character can appear in the password + * @var int + */ + private static int $maxOccurrences = 2; - /** - * Get the base amount of characters from the characters used in the password. - * This is the number of possible characters to pick from in the used character sets - * i.e. 26 for only lower case passwords - * @param $password - * @return int - */ - public static function getBase(string $password): int - { - $characters = str_split($password); - $base = 0; - $hasSpecial = false; - $hasLower = false; - $hasUpper = false; - $hasDigits = false; + /** + * Get the base amount of characters from the characters used in the password. + * This is the number of possible characters to pick from in the used character sets + * i.e. 26 for only lower case passwords + * @param $password + * @return int + */ + public static function getBase(string $password): int + { + $characters = str_split($password); + $base = 0; + $hasSpecial = false; + $hasLower = false; + $hasUpper = false; + $hasDigits = false; - foreach ($characters as $character) { - if (!$hasLower && strpos(self::$lowercaseCharacters, $character) !== false) { - $hasLower = true; - $base += strlen(self::$lowercaseCharacters); - } - if (!$hasUpper && strpos(self::$uppercaseCharacters, $character) !== false) { - $hasUpper = true; - $base += strlen(self::$uppercaseCharacters); - } - if (!$hasSpecial && strpos(self::$specialCharacters, $character) !== false) { - $hasSpecial = true; - $base += strlen(self::$specialCharacters); - } - if (!$hasDigits && strpos(self::$numbers, $character) !== false) { - $hasDigits = true; - $base += strlen(self::$numbers); - } + foreach ($characters as $character) { + if (!$hasLower && strpos(self::$lowercaseCharacters, $character) !== false) { + $hasLower = true; + $base += strlen(self::$lowercaseCharacters); + } + if (!$hasUpper && strpos(self::$uppercaseCharacters, $character) !== false) { + $hasUpper = true; + $base += strlen(self::$uppercaseCharacters); + } + if (!$hasSpecial && strpos(self::$specialCharacters, $character) !== false) { + $hasSpecial = true; + $base += strlen(self::$specialCharacters); + } + if (!$hasDigits && strpos(self::$numbers, $character) !== false) { + $hasDigits = true; + $base += strlen(self::$numbers); + } - if ( - strpos(self::$lowercaseCharacters, $character) === false - && strpos(self::$uppercaseCharacters, $character) === false - && strpos(self::$specialCharacters, $character) === false - && strpos(self::$numbers, $character) === false - ) { - $base++; - } - } + if ( + strpos(self::$lowercaseCharacters, $character) === false + && strpos(self::$uppercaseCharacters, $character) === false + && strpos(self::$specialCharacters, $character) === false + && strpos(self::$numbers, $character) === false + ) { + $base++; + } + } - return $base; - } + return $base; + } - /** - * get the calculated entropy of the password based on the rules for excluding duplicate characters - * If a password is in the banned list, entropy will be 0. - * @see bannedPassords() - * @param string $password - * @param array $bannedPasswords a custom list of passwords to disallow - * @return float - */ - public static function getEntropy(string $password, array $bannedPasswords = []): float - { - if (in_array(strtolower($password), $bannedPasswords)) { - // these are so weak, we just want to outright ban them. Entropy will be 0 for anything in this list. - return 0; - } - $base = self::getBase($password); - $length = self::getLength($password); + /** + * get the calculated entropy of the password based on the rules for excluding duplicate characters + * If a password is in the banned list, entropy will be 0. + * @see bannedPassords() + * @param string $password + * @param array $bannedPasswords a custom list of passwords to disallow + * @return float + */ + public static function getEntropy(string $password, array $bannedPasswords = []): float + { + if (in_array(strtolower($password), $bannedPasswords)) { + // these are so weak, we just want to outright ban them. Entropy will be 0 for anything in this list. + return 0; + } + $base = self::getBase($password); + $length = self::getLength($password); - $decimalPlaces = 2; - return number_format(log($base ** $length), $decimalPlaces); - } + $decimalPlaces = 2; + return number_format(log($base ** $length), $decimalPlaces); + } - /** - * Check the length of the password based on known rules - * Characters will only be counted a maximum of 2 times e.g. aaa has length 2 - * @param $password - * @return int - */ - public static function getLength(string $password): int - { - $usedCharacters = []; - $characters = str_split($password); - $length = 0; + /** + * Check the length of the password based on known rules + * Characters will only be counted a maximum of 2 times e.g. aaa has length 2 + * @param $password + * @return int + */ + public static function getLength(string $password): int + { + $usedCharacters = []; + $characters = str_split($password); + $length = 0; - foreach ($characters as $character) - { - if (array_key_exists($character, $usedCharacters) && $usedCharacters[$character] < self::$maxOccurrences) { - $length++; - $usedCharacters[$character]++; - } - if (!array_key_exists($character, $usedCharacters)) { - $usedCharacters[$character] = 1; - $length++; - } - } + foreach ($characters as $character) + { + if (array_key_exists($character, $usedCharacters) && $usedCharacters[$character] < self::$maxOccurrences) { + $length++; + $usedCharacters[$character]++; + } + if (!array_key_exists($character, $usedCharacters)) { + $usedCharacters[$character] = 1; + $length++; + } + } - return $length; - } + return $length; + } } diff --git a/lib/Routes/Account.php b/lib/Routes/Account.php new file mode 100644 index 0000000..87ed8c8 --- /dev/null +++ b/lib/Routes/Account.php @@ -0,0 +1,183 @@ + $_POST['email'] + ]; + + $verifyToken = User::saveVerifyToken('verify', $verifyData); + Mailer::sendVerify($verifyToken); + + $responseData = "OK"; + header("HTTP/1.1 201 Created"); + header("Content-type: application/json"); + echo json_encode($responseData, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + } + + public static function respondToAccountNew() { + $verifyToken = User::getVerifyToken($_POST['confirm']); + if (!$verifyToken) { + error_log("Could not read verify token"); + header("HTTP/1.1 400 Bad Request"); + exit(); + } + if ($verifyToken['email'] !== $_POST['email']) { + error_log("Verify token does not match email"); + header("HTTP/1.1 400 Bad Request"); + exit(); + } + if (User::userEmailExists($_POST['email'])) { + error_log("Account already exists"); + header("HTTP/1.1 400 Bad Request"); + exit(); + } + if (!$_POST['password'] === $_POST['repeat_password']) { + error_log("Password repeat does not match"); + header("HTTP/1.1 400 Bad Request"); + exit(); + } + + $newUser = [ + "email" => $_POST['email'], + "password" => $_POST['password'] + ]; + + $createdUser = User::createUser($newUser); + if (!$createdUser) { + error_log("Failed to create user"); + header("HTTP/1.1 400 Bad Request"); + exit(); + } + Mailer::sendAccountCreated($createdUser); + + $responseData = array( + "webId" => $createdUser['webId'] + ); + header("HTTP/1.1 201 Created"); + header("Content-type: application/json"); + Session::start($_POST['email']); + echo json_encode($responseData, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + } + + public static function respondToAccountResetPassword() { + if (!User::userEmailExists($_POST['email'])) { + header("HTTP/1.1 200 OK"); // Return OK even when user is not found; + header("Content-type: application/json"); + echo json_encode("OK"); + exit(); + } + $verifyData = [ + 'email' => $_POST['email'] + ]; + + $verifyToken = User::saveVerifyToken('passwordReset', $verifyData); + Mailer::sendResetPassword($verifyToken); + header("HTTP/1.1 200 OK"); + header("Content-type: application/json"); + echo json_encode("OK"); + } + + public static function respondToAccountChangePassword() { + $verifyToken = User::getVerifyToken($_POST['token']); + if (!$verifyToken) { + header("HTTP/1.1 400 Bad Request"); + exit(); + } + $result = User::setUserPassword($verifyToken['email'], $_POST['newPassword']); + if (!$result) { + header("HTTP/1.1 400 Bad Request"); + exit(); + } + header("HTTP/1.1 200 OK"); + header("Content-type: application/json"); + echo json_encode("OK"); + } + + public static function respondToAccountDelete() { + if (!User::userEmailExists($_POST['email'])) { + header("HTTP/1.1 200 OK"); // Return OK even when user is not found; + header("Content-type: application/json"); + echo json_encode("OK"); + exit(); + } + $verifyData = [ + 'email' => $_POST['email'] + ]; + + $verifyToken = User::saveVerifyToken('deleteAccount', $verifyData); + Mailer::sendDeleteAccount($verifyToken); + header("HTTP/1.1 200 OK"); + header("Content-type: application/json"); + echo json_encode("OK"); + } + + public static function respondToAccountDeleteConfirm() { + $verifyToken = User::getVerifyToken($_POST['token']); + if (!$verifyToken) { + header("HTTP/1.1 400 Bad Request"); + exit(); + } + User::deleteAccount($verifyToken['email']); + header("HTTP/1.1 200 OK"); + header("Content-type: application/json"); + echo json_encode("OK"); + } + + public static function respondToLogin() { + $failureCount = IpAttempts::getAttemptsCount($_SERVER['REMOTE_ADDR'], "login"); + if ($failureCount > 5) { + header("HTTP/1.1 400 Bad Request"); + exit(); + } + if (User::checkPassword($_POST['username'], $_POST['password'])) { + Session::start($_POST['username']); + if (!isset($_POST['redirect_uri']) || $_POST['redirect_uri'] === '') { + header("Location: /dashboard/"); + exit(); + } + header("Location: " . urldecode($_POST['redirect_uri'])); // FIXME: Do we need to harden this? + } else { + IpAttempts::logFailedAttempt($_SERVER['REMOTE_ADDR'], "login", time() + 3600); + header("Location: /login/"); + } + } + } diff --git a/lib/Routes/SolidIdp.php b/lib/Routes/SolidIdp.php new file mode 100644 index 0000000..4d15982 --- /dev/null +++ b/lib/Routes/SolidIdp.php @@ -0,0 +1,209 @@ +respondToJwksMetadataRequest(); + Server::respond($response); + } + + public static function respondToWellKnownOpenIdConfiguration() { + $authServer = Server::getAuthServer(); + $response = $authServer->respondToOpenIdMetadataRequest(); + Server::respond($response); + } + + public static function respondToAuthorize() { + $user = User::getUser(Session::getLoggedInUser()); + + $clientId = $_GET['client_id']; + $getVars = $_GET; + + if (!isset($getVars['grant_type'])) { + $getVars['grant_type'] = 'implicit'; + } + $getVars['scope'] = "openid" ; + $getVars['response_type'] = "token"; + + $requestedResponseTypes = explode(" ", ($_GET['response_type'] ?? '')); + if (in_array("code", $requestedResponseTypes)) { + $getVars['response_type'] = "code"; + } + + $keys = Server::getKeys(); + if (isset($_GET['request'])) { + $jwtConfig = \Lcobucci\JWT\Configuration::forSymmetricSigner( + new \Lcobucci\JWT\Signer\Rsa\Sha256(), + \Lcobucci\JWT\Signer\Key\InMemory::plainText($keys['privateKey'] + )); + + if (isset($_GET['nonce'])) { + $_SESSION['nonce'] = $_GET['nonce']; + } else if (isset($_GET['request'])) { + $token = $jwtConfig->parser()->parse($_GET['request']); + $_SESSION['nonce'] = $token->claims()->get('nonce'); + } + + if (!isset($getVars["redirect_uri"])) { + if (isset($token)) { + $getVars['redirect_uri'] = $token->claims()->get("redirect_uri"); + } + } + } + + $requestFactory = new \Laminas\Diactoros\ServerRequestFactory(); + $request = $requestFactory->fromGlobals($_SERVER, $getVars, $_POST, $_COOKIE, $_FILES); + + $authServer = Server::getAuthServer(); + + $approval = false; + // check clientId approval for the user + if (in_array($clientId, ($user['allowedClients'] ?? []))) { + $approval = true; + } else { + $clientRegistration = ClientRegistration::getRegistration($clientId); + if (in_array($clientRegistration['origin'], TRUSTED_APPS)) { + $approval = true; + } + } + + if (!$approval) { + header('Location: ' . BASEURL . '/sharing/' . "?" . http_build_query( + array( + "returnUrl" => urlencode($_SERVER["REQUEST_URI"]), + "client_id" => $clientId, + "redirect_uri" => $getVars['redirect_uri'] + ) + )); + exit(); + } + + $webId = "https://id-" . $user['userId'] . "." . BASEDOMAIN . "/#me"; + $user = new \Pdsinterop\Solid\Auth\Entity\User(); + $user->setIdentifier($webId); + + $response = $authServer->respondToAuthorizationRequest($request, $user, $approval); + + $tokenGenerator = Server::getTokenGenerator(); + + $response = $tokenGenerator->addIdTokenToResponse( + $response, + $clientId, + $webId, + $_SESSION['nonce'] ?? '', + Server::getKeys()["privateKey"] + ); + + Server::respond($response); + } + + public static function respondToRegister() { + $postData = file_get_contents("php://input"); + $clientData = json_decode($postData, true); + if (!isset($clientData)) { + header("HTTP/1.1 400 Bad request"); + return; + } + $parsedOrigin = parse_url($clientData['redirect_uris'][0]); // FIXME: Should we have multiple origins? + $origin = $parsedOrigin['scheme'] . '://' . $parsedOrigin['host']; + if (isset($parsedOrigin['port'])) { + $origin .= ":" . $parsedOrigin['port']; + } + + + $generatedClientId = bin2hex(random_bytes(16)); // 32 chars for the client Id + $generatedClientSecret = bin2hex(random_bytes(32)); // and 64 chars for the client secret + + $clientData['client_id_issued_at'] = time(); + $clientData['client_id'] = $generatedClientId; + $clientData['client_secret'] = $generatedClientSecret; + $clientData['origin'] = $origin; + ClientRegistration::saveClientRegistration($clientData); + + $client = ClientRegistration::getRegistration($generatedClientId); + + $responseData = array( + 'redirect_uris' => $client['redirect_uris'], + 'client_id' => $client['client_id'], + 'client_secret' => $client['client_secret'], + 'response_types' => array('code'), + 'grant_types' => array('authorization_code', 'refresh_token'), + 'application_type' => $client['application_type'] ?? 'web', + 'client_name' => $client['client_name'] ?? $client['client_id'], + 'id_token_signed_response_alg' => 'RS256', + 'token_endpoint_auth_method' => 'client_secret_basic', + 'client_id_issued_at' => $client['client_id_issued_at'], + 'client_secret_expires_at' => 0 + ); + header("HTTP/1.1 201 Created"); + header("Content-type: application/json"); + echo json_encode($responseData, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); + } + + public static function respondToSharing() { + $user = User::getUser(Session::getLoggedInUser()); + $clientId = $_POST['client_id']; + $userId = $user['userId']; + if ($_POST['consent'] === 'true') { + User::allowClientForUser($clientId, $userId); + } + $returnUrl = urldecode($_POST['returnUrl']); + header("Location: $returnUrl"); + } + + public static function respondToToken() { + $authServer = Server::getAuthServer(); + $tokenGenerator = Server::getTokenGenerator(); + + $requestFactory = new \Laminas\Diactoros\ServerRequestFactory(); + $request = $requestFactory->fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); + $requestBody = $request->getParsedBody(); + + $grantType = $requestBody['grant_type'] ?? null; + $clientId = $requestBody['client_id'] ?? null; + switch ($grantType) { + case "authorization_code": + $code = $requestBody['code']; + $codeInfo = $tokenGenerator->getCodeInfo($code); + $userId = $codeInfo['user_id']; + if (!$clientId) { + $clientId = $codeInfo['client_id']; + } + break; + case "refresh_token": + $refreshToken = $requestBody['refresh_token']; + $tokenInfo = $tokenGenerator->getCodeInfo($refreshToken); // FIXME: getCodeInfo should be named 'decrypt' or 'getInfo'? + $userId = $tokenInfo['user_id']; + if (!$clientId) { + $clientId = $tokenInfo['client_id']; + } + break; + default: + $userId = false; + break; + } + + $httpDpop = $request->getServerParams()['HTTP_DPOP']; + + $response = $authServer->respondToAccessTokenRequest($request); + + if (isset($userId)) { + $response = $tokenGenerator->addIdTokenToResponse( + $response, + $clientId, + $userId, + ($_SESSION['nonce'] ?? ''), + Server::getKeys()['privateKey'], + $httpDpop + ); + } + + Server::respond($response); + } + } diff --git a/lib/Routes/SolidStorage.php b/lib/Routes/SolidStorage.php new file mode 100644 index 0000000..5b90424 --- /dev/null +++ b/lib/Routes/SolidStorage.php @@ -0,0 +1,73 @@ +fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); + + StorageServer::initializeStorage(); + $filesystem = StorageServer::getFileSystem(); + + $resourceServer = new ResourceServer($filesystem, new Response(), null); + $solidNotifications = new SolidNotifications(); + $resourceServer->setNotifications($solidNotifications); + + $wac = new WAC($filesystem); + + $baseUrl = Util::getServerBaseUrl(); + $resourceServer->setBaseUrl($baseUrl); + $wac->setBaseUrl($baseUrl); + + $webId = StorageServer::getWebId($rawRequest); + + if (!isset($webId)) { + $response = $resourceServer->getResponse() + ->withStatus(409, "Invalid token"); + StorageServer::respond($response); + exit(); + } + + $origin = $rawRequest->getHeaderLine("Origin"); + + // FIXME: Read allowed clients from the profile instead; + $owner = StorageServer::getOwner(); + + $allowedClients = $owner['allowedClients'] ?? []; + $allowedOrigins = []; + foreach ($allowedClients as $clientId) { + $clientRegistration = ClientRegistration::getRegistration($clientId); + if (isset($clientRegistration['client_name'])) { + $allowedOrigins[] = $clientRegistration['client_name']; + } + if (isset($clientRegistration['origin'])) { + $allowedOrigins[] = $clientRegistration['origin']; + } + } + if (!isset($origin) || ($origin === "")) { + $allowedOrigins[] = "app://unset"; // FIXME: this should not be here. + $origin = "app://unset"; + } + + if (!$wac->isAllowed($rawRequest, $webId, $origin, $allowedOrigins)) { + $response = new Response(); + $response = $response->withStatus(403, "Access denied!"); + StorageServer::respond($response); + exit(); + } + + $response = $resourceServer->respondToRequest($rawRequest); + $response = $wac->addWACHeaders($rawRequest, $response, $webId); + StorageServer::respond($response); + } + } + \ No newline at end of file diff --git a/lib/Routes/SolidUserProfile.php b/lib/Routes/SolidUserProfile.php new file mode 100644 index 0000000..78ae9ac --- /dev/null +++ b/lib/Routes/SolidUserProfile.php @@ -0,0 +1,52 @@ +. +@prefix acl: . +@prefix foaf: . +@prefix ldp: . +@prefix schema: . +@prefix solid: . +@prefix space: . +@prefix vcard: . +@prefix pro: <./>. +@prefix inbox: <{$user['storage']}inbox/>. + +<> a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. + +:me + a schema:Person, foaf:Person; + ldp:inbox inbox:; + space:preferencesFile <{$user['storage']}settings/prefs.ttl>; + space:storage <{$user['storage']}>; + solid:account <{$user['storage']}>; + solid:oidcIssuer <{$user['issuer']}>; + solid:privateTypeIndex <{$user['storage']}settings/privateTypeIndex.ttl>; + solid:publicTypeIndex <{$user['storage']}settings/publicTypeIndex.ttl>. +EOF; + header('Content-Type: text/turtle'); + echo $profile; + } + } + \ No newline at end of file diff --git a/lib/Server.php b/lib/Server.php index 39317ec..9913ece 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -161,6 +161,6 @@ public static function respond($response) { header($header . ":" . $value); } } - echo json_encode($body, JSON_PRETTY_PRINT); + echo json_encode($body, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); } } diff --git a/lib/Session.php b/lib/Session.php index 4711287..7a707e3 100644 --- a/lib/Session.php +++ b/lib/Session.php @@ -2,16 +2,19 @@ namespace Pdsinterop\PhpSolid; class Session { - private $cookieLifetime = 24*60*60; public static function start($username) { - session_start([ - 'cookie_lifetime' => 24*60*60 // 1 day - ]); + if (session_status() === PHP_SESSION_NONE) { + session_start([ + 'cookie_lifetime' => 24*60*60 // 1 day + ]); + } $_SESSION['username'] = $username; } public static function getLoggedInUser() { - session_start(); + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } if (!isset($_SESSION['username'])) { return false; } diff --git a/lib/SolidPubSub.php b/lib/SolidPubSub.php index 7cf2663..36f49d1 100644 --- a/lib/SolidPubSub.php +++ b/lib/SolidPubSub.php @@ -1,30 +1,30 @@ pubsub = $pubsubUrl; - } + class SolidPubSub implements SolidNotificationsInterface + { + private $pubsub; + public function __construct($pubsubUrl) { + $this->pubsub = $pubsubUrl; + } - public function send($path, $type) { - $pubsub = str_replace(["https://", "http://"], "wss://", $this->pubsub); + public function send($path, $type) { + $pubsub = str_replace(["https://", "http://"], "wss://", $this->pubsub); - $client = new Client($pubsub); - $client->setContext(['ssl' => [ - 'verify_peer' => false, // if false, accept SSL handshake without client certificate - 'verify_peer_name' => false, - 'allow_self_signed' => true, - ]]); - $client->addHeader("Sec-WebSocket-Protocol", "solid-0.1"); - try { - $client->text("pub $path\n"); - } catch (\Throwable $exception) { - throw new \Exception('Could not write to pubsub server', 502, $exception); - } - } - } + $client = new Client($pubsub); + $client->setContext(['ssl' => [ + 'verify_peer' => false, // if false, accept SSL handshake without client certificate + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ]]); + $client->addHeader("Sec-WebSocket-Protocol", "solid-0.1"); + try { + $client->text("pub $path\n"); + } catch (\Throwable $exception) { + throw new \Exception('Could not write to pubsub server', 502, $exception); + } + } + } diff --git a/lib/StorageServer.php b/lib/StorageServer.php index baafaa1..12ac7d0 100644 --- a/lib/StorageServer.php +++ b/lib/StorageServer.php @@ -3,6 +3,7 @@ use Pdsinterop\PhpSolid\Server; use Pdsinterop\PhpSolid\User; + use Pdsinterop\PhpSolid\Util; class StorageServer extends Server { public static function getFileSystem() { @@ -10,18 +11,14 @@ public static function getFileSystem() { // The internal adapter $adapter = new \League\Flysystem\Adapter\Local( - // Determine root directory - STORAGEBASE . "$storageId/" + // Determine root directory + STORAGEBASE . "$storageId/" ); $graph = new \EasyRdf\Graph(); // Create Formats objects $formats = new \Pdsinterop\Rdf\Formats(); - - $scheme = $_SERVER['REQUEST_SCHEME']; - $domain = $_SERVER['SERVER_NAME']; - $path = $_SERVER['REQUEST_URI']; - $serverUri = "{$scheme}://{$domain}{$path}"; // FIXME: doublecheck that this is the correct url; + $serverUri = Util::getServerUri(); // Create the RDF Adapter $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf($adapter, $graph, $formats, $serverUri); @@ -62,7 +59,7 @@ public static function getWebId($rawRequest) { } private static function getStorageId() { - $serverName = $_SERVER['SERVER_NAME']; + $serverName = Util::getServerName(); $idParts = explode(".", $serverName, 2); $storageId = preg_replace("/^storage-/", "", $idParts[0]); return $storageId; diff --git a/lib/User.php b/lib/User.php index 2b108d4..2affb0b 100644 --- a/lib/User.php +++ b/lib/User.php @@ -13,7 +13,7 @@ private static function generateTokenCode() { } private static function generateTokenHex() { - return md5(random_bytes(32)); + return bin2hex(random_bytes(16)); } private static function generateExpiresTimestamp($lifetime) { @@ -89,9 +89,9 @@ public static function createUser($newUser) { if (!self::validatePasswordStrength($newUser['password'])) { return false; } - $generatedUserId = md5(random_bytes(32)); + $generatedUserId = bin2hex(random_bytes(16)); while (self::userIdExists($generatedUserId)) { - $generatedUserId = md5(random_bytes(32)); + $generatedUserId = bin2hex(random_bytes(16)); } $query = Db::$pdo->prepare( 'INSERT INTO users VALUES (:userId, :email, :passwordHash, :data)' diff --git a/lib/Util.php b/lib/Util.php index 2b66f49..642e687 100644 --- a/lib/Util.php +++ b/lib/Util.php @@ -9,4 +9,19 @@ public static function base64_url_encode($text) { public static function base64_url_decode($text) { return base64_decode(str_replace(['-', '_', ''], ['+', '/', '='], $text)); } + public static function getServerName() { + // FIXME: Depending on the setup, SERVER_NAME might not be the domain of the server. + return $_SERVER['SERVER_NAME']; + } + public static function getServerUri() { + $scheme = $_SERVER['REQUEST_SCHEME']; + $domain = $_SERVER['SERVER_NAME']; + $path = $_SERVER['REQUEST_URI']; + return "{$scheme}://{$domain}{$path}"; + } + public static function getServerBaseUrl() { + $scheme = $_SERVER['REQUEST_SCHEME']; + $domain = $_SERVER['SERVER_NAME']; + return "{$scheme}://{$domain}"; + } } diff --git a/www/idp/index.php b/www/idp/index.php index 01126d0..40d0473 100644 --- a/www/idp/index.php +++ b/www/idp/index.php @@ -7,11 +7,10 @@ require_once(__DIR__ . "/../../vendor/autoload.php"); use Pdsinterop\PhpSolid\Middleware; - use Pdsinterop\PhpSolid\Server; - use Pdsinterop\PhpSolid\ClientRegistration; + use Pdsinterop\PhpSolid\Routes\Account; + use Pdsinterop\PhpSolid\Routes\SolidIdp; + use Pdsinterop\PhpSolid\User; - use Pdsinterop\PhpSolid\Session; - use Pdsinterop\PhpSolid\Mailer; use Pdsinterop\PhpSolid\IpAttempts; use Pdsinterop\PhpSolid\JtiStore; @@ -25,123 +24,24 @@ switch ($request) { case "/jwks": case "/jwks/": - $authServer = Server::getAuthServer(); - $response = $authServer->respondToJwksMetadataRequest(); - Server::respond($response); + SolidIdp::respondToJwks(); break; case "/.well-known/openid-configuration": - header("Content-type: application/json"); - $authServer = Server::getAuthServer(); - $response = $authServer->respondToOpenIdMetadataRequest(); - Server::respond($response); + SolidIdp::respondToWellKnownOpenIdConfiguration(); break; case "/authorize": case "/authorize/": - $user = User::getUser(Session::getLoggedInUser()); - if (!$user) { - header("Location: /login/?redirect_uri=" . urlencode($_SERVER['REQUEST_URI'])); - exit(); - } - - $clientId = $_GET['client_id']; - $getVars = $_GET; - - if (!isset($getVars['grant_type'])) { - $getVars['grant_type'] = 'implicit'; - } - $getVars['scope'] = "openid" ; - $getVars['response_type'] = "token"; - - $requestedResponseTypes = explode(" ", ($_GET['response_type'] ?? '')); - foreach ($requestedResponseTypes as $responseType) { - if ($responseType == "code") { - $getVars['response_type'] = "code"; - } - } - - $keys = Server::getKeys(); - if (isset($_GET['request'])) { - $jwtConfig = \Lcobucci\JWT\Configuration::forSymmetricSigner( - new \Lcobucci\JWT\Signer\Rsa\Sha256(), - \Lcobucci\JWT\Signer\Key\InMemory::plainText($keys['privateKey'] - )); - - if (isset($_GET['nonce'])) { - $_SESSION['nonce'] = $_GET['nonce']; - } else if (isset($_GET['request'])) { - $token = $jwtConfig->parser()->parse($_GET['request']); - $_SESSION['nonce'] = $token->claims()->get('nonce'); - } - - if (!isset($getVars["redirect_uri"])) { - if (isset($token)) { - $getVars['redirect_uri'] = $token->claims()->get("redirect_uri"); - } - } - } - - $requestFactory = new \Laminas\Diactoros\ServerRequestFactory(); - $request = $requestFactory->fromGlobals($_SERVER, $getVars, $_POST, $_COOKIE, $_FILES); - - $authServer = Server::getAuthServer(); - - $approval = false; - // check clientId approval for the user - if (in_array($clientId, ($user['allowedClients'] ?? []))) { - $approval = true; - } else { - $clientRegistration = ClientRegistration::getRegistration($clientId); - if (in_array($clientRegistration['origin'], TRUSTED_APPS)) { - $approval = true; - } - } - - if (!$approval) { - header('Location: ' . BASEURL . '/sharing/' . "?" . http_build_query( - array( - "returnUrl" => urlencode($_SERVER["REQUEST_URI"]), - "client_id" => $clientId, - "redirect_uri" => $getVars['redirect_uri'] - ) - )); - exit(); - } - - $webId = "https://id-" . $user['userId'] . "." . BASEDOMAIN . "/#me"; - $user = new \Pdsinterop\Solid\Auth\Entity\User(); - $user->setIdentifier($webId); - - $response = $authServer->respondToAuthorizationRequest($request, $user, $approval); - - $tokenGenerator = Server::getTokenGenerator(); - - $response = $tokenGenerator->addIdTokenToResponse( - $response, - $clientId, - $webId, - $_SESSION['nonce'] ?? '', - Server::getKeys()["privateKey"] - ); - - Server::respond($response); + Account::requireLoggedInUser(); + SolidIdp::respondToAuthorize(); break; case "/dashboard": case "/dashboard/": - $user = User::getUser(Session::getLoggedInUser()); - if (!$user) { - header("Location: /login/"); - exit(); - } - echo "Logged in as " . $user['webId']; + Account::requireLoggedInUser(); + Account::respondToDashboard(); break; case "/logout": case "/logout/": - $user = User::getUser(Session::getLoggedInUser()); - if ($user) { - session_destroy(); - } - header("Location: /login/"); - exit(); + Account::respondToLogout(); break; case "/login/password": case "/login/password/": @@ -164,11 +64,7 @@ break; case "/sharing": case "/sharing/": - $user = User::getUser(Session::getLoggedInUser()); - if (!$user) { - header("Location: /login/"); - exit(); - } + Account::requireLoggedInUser(); include_once(FRONTENDDIR . "generated.html"); break; case '/session': @@ -186,264 +82,48 @@ switch ($request) { case "/api/accounts/verify": case "/api/accounts/verify/": - $verifyData = [ - 'email' => $_POST['email'] - ]; - - $verifyToken = User::saveVerifyToken('verify', $verifyData); - Mailer::sendVerify($verifyToken); - - $responseData = "OK"; - header("HTTP/1.1 201 Created"); - header("Content-type: application/json"); - echo json_encode($responseData, JSON_PRETTY_PRINT); + Account::respondToAccountVerify(); break; case "/api/accounts/new": case "/api/accounts/new/": - $verifyToken = User::getVerifyToken($_POST['confirm']); - if (!$verifyToken) { - error_log("Could not read verify token"); - header("HTTP/1.1 400 Bad Request"); - exit(); - } - if ($verifyToken['email'] !== $_POST['email']) { - error_log("Verify token does not match email"); - header("HTTP/1.1 400 Bad Request"); - exit(); - } - if (User::userEmailExists($_POST['email'])) { - error_log("Account already exists"); - header("HTTP/1.1 400 Bad Request"); - exit(); - } - if (!$_POST['password'] === $_POST['repeat_password']) { - error_log("Password repeat does not match"); - header("HTTP/1.1 400 Bad Request"); - exit(); - } - - $newUser = [ - "email" => $_POST['email'], - "password" => $_POST['password'] - ]; - - $createdUser = User::createUser($newUser); - if (!$createdUser) { - error_log("Failed to create user"); - header("HTTP/1.1 400 Bad Request"); - exit(); - } - Mailer::sendAccountCreated($createdUser); - - $responseData = array( - "webId" => $createdUser['webId'] - ); - header("HTTP/1.1 201 Created"); - header("Content-type: application/json"); - Session::start($_POST['email']); - echo json_encode($responseData, JSON_PRETTY_PRINT); + Account::respondToAccountNew(); break; case "/api/accounts/reset-password": case "/api/accounts/reset-password/": - if (!User::userEmailExists($_POST['email'])) { - header("HTTP/1.1 200 OK"); // Return OK even when user is not found; - header("Content-type: application/json"); - echo json_encode("OK"); - exit(); - } - $verifyData = [ - 'email' => $_POST['email'] - ]; - - $verifyToken = User::saveVerifyToken('passwordReset', $verifyData); - Mailer::sendResetPassword($verifyToken); - header("HTTP/1.1 200 OK"); - header("Content-type: application/json"); - echo json_encode("OK"); + Account::respondToAccountResetPassword(); break; case "/api/accounts/change-password": case "/api/accounts/change-password/": - $verifyToken = User::getVerifyToken($_POST['token']); - if (!$verifyToken) { - header("HTTP/1.1 400 Bad Request"); - exit(); - } - $result = User::setUserPassword($verifyToken['email'], $_POST['newPassword']); - if (!$result) { - header("HTTP/1.1 400 Bad Request"); - exit(); - } - header("HTTP/1.1 200 OK"); - header("Content-type: application/json"); - echo json_encode("OK"); + Account::respondToAccountChangePassword(); break; case "/api/accounts/delete": case "/api/accounts/delete/": - if (!User::userEmailExists($_POST['email'])) { - header("HTTP/1.1 200 OK"); // Return OK even when user is not found; - header("Content-type: application/json"); - echo json_encode("OK"); - exit(); - } - $verifyData = [ - 'email' => $_POST['email'] - ]; - - $verifyToken = User::saveVerifyToken('deleteAccount', $verifyData); - Mailer::sendDeleteAccount($verifyToken); - header("HTTP/1.1 200 OK"); - header("Content-type: application/json"); - echo json_encode("OK"); + Account::respondToAccountDelete(); break; case "/api/accounts/delete/confirm": case "/api/accounts/delete/confirm/": - $verifyToken = User::getVerifyToken($_POST['token']); - if (!$verifyToken) { - header("HTTP/1.1 400 Bad Request"); - exit(); - } - User::deleteAccount($verifyToken['email']); - header("HTTP/1.1 200 OK"); - header("Content-type: application/json"); - echo json_encode("OK"); + Account::respondToAccountDeleteConfirm(); break; case "/login/password": case "/login/password/": - $failureCount = IpAttempts::getAttemptsCount($_SERVER['REMOTE_ADDR'], "login"); - if ($failureCount > 5) { - header("HTTP/1.1 400 Bad Request"); - exit(); - } - if (User::checkPassword($_POST['username'], $_POST['password'])) { - Session::start($_POST['username']); - if (!isset($_POST['redirect_uri']) || $_POST['redirect_uri'] === '') { - header("Location: /dashboard/"); - exit(); - } - header("Location: " . urldecode($_POST['redirect_uri'])); // FIXME: Do we need to harden this? - } else { - IpAttempts::logFailedAttempt($_SERVER['REMOTE_ADDR'], "login", time() + 3600); - header("Location: /login/"); - } + Account::respondToLogin(); break; case "/register": case "/register/": - $postData = file_get_contents("php://input"); - $clientData = json_decode($postData, true); - if (!isset($clientData)) { - header("HTTP/1.1 400 Bad request"); - return; - } - $parsedOrigin = parse_url($clientData['redirect_uris'][0]); - $origin = $parsedOrigin['scheme'] . '://' . $parsedOrigin['host']; - if (isset($parsedOrigin['port'])) { - $origin .= ":" . $parsedOrigin['port']; - } - - - $generatedClientId = md5(random_bytes(32)); - $generatedClientSecret = md5(random_bytes(32)); - - $clientData['client_id_issued_at'] = time(); - $clientData['client_id'] = $generatedClientId; - $clientData['client_secret'] = $generatedClientSecret; - $clientData['origin'] = $origin; - ClientRegistration::saveClientRegistration($clientData); - - $client = ClientRegistration::getRegistration($generatedClientId); - - $responseData = array( - 'redirect_uris' => $client['redirect_uris'], - 'client_id' => $client['client_id'], - 'client_secret' => $client['client_secret'], - 'response_types' => array('code'), - 'grant_types' => array('authorization_code', 'refresh_token'), - 'application_type' => $client['application_type'] ?? 'web', - 'client_name' => $client['client_name'] ?? $client['client_id'], - 'id_token_signed_response_alg' => 'RS256', - 'token_endpoint_auth_method' => 'client_secret_basic', - 'client_id_issued_at' => $client['client_id_issued_at'], - 'client_secret_expires_at' => 0 - ); - header("HTTP/1.1 201 Created"); - header("Content-type: application/json"); - echo json_encode($responseData, JSON_PRETTY_PRINT); + SolidIdp::respondToRegister(); break; case "/api/sharing": case "/api/sharing/": - $user = User::getUser(Session::getLoggedInUser()); - if (!$user) { - header("HTTP/1.1 400 Bad request"); - } else { - $clientId = $_POST['client_id']; - $userId = $user['userId']; - if ($_POST['consent'] === 'true') { - User::allowClientForUser($clientId, $userId); - } - $returnUrl = urldecode($_POST['returnUrl']); - header("Location: $returnUrl"); - } + SolidIdp::respondToSharing(); break; case "/token": case "/token/": - $authServer = Server::getAuthServer(); - $tokenGenerator = Server::getTokenGenerator(); - - $requestFactory = new \Laminas\Diactoros\ServerRequestFactory(); - $request = $requestFactory->fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); - $requestBody = $request->getParsedBody(); - - $grantType = isset($requestBody['grant_type']) ? $requestBody['grant_type'] : null; - $clientId = isset($requestBody['client_id']) ? $requestBody['client_id'] : null; - switch ($grantType) { - case "authorization_code": - $code = $requestBody['code']; - $codeInfo = $tokenGenerator->getCodeInfo($code); - $userId = $codeInfo['user_id']; - if (!$clientId) { - $clientId = $codeInfo['client_id']; - } - break; - case "refresh_token": - $refreshToken = $requestBody['refresh_token']; - $tokenInfo = $tokenGenerator->getCodeInfo($refreshToken); // FIXME: getCodeInfo should be named 'decrypt' or 'getInfo'? - $userId = $tokenInfo['user_id']; - if (!$clientId) { - $clientId = $tokenInfo['client_id']; - } - break; - default: - $userId = false; - break; - } - - $httpDpop = $request->getServerParams()['HTTP_DPOP']; - - $response = $authServer->respondToAccessTokenRequest($request); - - if (isset($userId)) { - $response = $tokenGenerator->addIdTokenToResponse( - $response, - $clientId, - $userId, - ($_SESSION['nonce'] ?? ''), - Server::getKeys()['privateKey'], - $httpDpop - ); - } - - Server::respond($response); + SolidIdp::respondToToken(); break; default: header($_SERVER['SERVER_PROTOCOL'] . " 404 Not found"); break; } - if (!file_exists(CLEANUP_FILE) || (filemtime(CLEANUP_FILE) < time())) { - touch(CLEANUP_FILE, time() + 3600); - User::cleanupTokens(); - IpAttempts::cleanupAttempts(); - JtiStore::cleanupJti(); - } break; case "OPTIONS": break; @@ -452,4 +132,10 @@ header($_SERVER['SERVER_PROTOCOL'] . " 405 Method not allowed"); break; } + if (!file_exists(CLEANUP_FILE) || (filemtime(CLEANUP_FILE) < time())) { + touch(CLEANUP_FILE, time() + 3600); + User::cleanupTokens(); + IpAttempts::cleanupAttempts(); + JtiStore::cleanupJti(); + } \ No newline at end of file diff --git a/www/user/profile.php b/www/user/profile.php index c18e534..c96c561 100644 --- a/www/user/profile.php +++ b/www/user/profile.php @@ -6,8 +6,7 @@ require_once(__DIR__ . "/../../vendor/autoload.php"); use Pdsinterop\PhpSolid\Middleware; - use Pdsinterop\PhpSolid\Server; - use Pdsinterop\PhpSolid\User; + use Pdsinterop\PhpSolid\Routes\SolidUserProfile; $request = explode("?", $_SERVER['REQUEST_URI'], 2)[0]; $method = $_SERVER['REQUEST_METHOD']; @@ -18,47 +17,7 @@ case "GET": switch ($request) { case "/": - $serverName = $_SERVER['SERVER_NAME']; - [$idPart, $rest] = explode(".", $serverName, 2); - $userId = preg_replace("/^id-/", "", $idPart); - - $user = User::getUserById($userId); - 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; - } - - $profile = <<<"EOF" -@prefix : <#>. -@prefix acl: . -@prefix foaf: . -@prefix ldp: . -@prefix schema: . -@prefix solid: . -@prefix space: . -@prefix vcard: . -@prefix pro: <./>. -@prefix inbox: <{$user['storage']}inbox/>. - -<> a foaf:PersonalProfileDocument; foaf:maker :me; foaf:primaryTopic :me. - -:me - a schema:Person, foaf:Person; - ldp:inbox inbox:; - space:preferencesFile <{$user['storage']}settings/prefs.ttl>; - space:storage <{$user['storage']}>; - solid:account <{$user['storage']}>; - solid:oidcIssuer <{$user['issuer']}>; - solid:privateTypeIndex <{$user['storage']}settings/privateTypeIndex.ttl>; - solid:publicTypeIndex <{$user['storage']}settings/publicTypeIndex.ttl>. -EOF; - header('Content-Type: text/turtle'); - echo $profile; + SolidUserProfile::respondToProfile(); break; } break; diff --git a/www/user/storage.php b/www/user/storage.php index b8f5eb8..6ada774 100644 --- a/www/user/storage.php +++ b/www/user/storage.php @@ -6,15 +6,8 @@ require_once(__DIR__ . "/../../vendor/autoload.php"); use Pdsinterop\PhpSolid\Middleware; - use Pdsinterop\PhpSolid\StorageServer; - use Pdsinterop\PhpSolid\ClientRegistration; - use Pdsinterop\PhpSolid\SolidNotifications; - use Pdsinterop\Solid\Auth\WAC; - use Pdsinterop\Solid\Resources\Server as ResourceServer; - use Laminas\Diactoros\ServerRequestFactory; - use Laminas\Diactoros\Response; + use Pdsinterop\PhpSolid\Routes\SolidStorage; - $request = explode("?", $_SERVER['REQUEST_URI'], 2)[0]; $method = $_SERVER['REQUEST_METHOD']; Middleware::cors(); @@ -25,63 +18,7 @@ echo "OK"; return; break; + default: + SolidStorage::respondToStorage(); + break; } - - $requestFactory = new ServerRequestFactory(); - $rawRequest = $requestFactory->fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES); - $response = new Response(); - - StorageServer::initializeStorage(); - $filesystem = StorageServer::getFileSystem(); - - $resourceServer = new ResourceServer($filesystem, $response, null); - $solidNotifications = new SolidNotifications(); - $resourceServer->setNotifications($solidNotifications); - - $wac = new WAC($filesystem); - - $baseUrl = $_SERVER['REQUEST_SCHEME'] . "://" . $_SERVER['SERVER_NAME']; - - $resourceServer->setBaseUrl($baseUrl); - $wac->setBaseUrl($baseUrl); - - $webId = StorageServer::getWebId($rawRequest); - - if (!isset($webId)) { - $response = $resourceServer->getResponse() - ->withStatus(409, "Invalid token"); - StorageServer::respond($response); - exit(); - } - - $origin = $rawRequest->getHeaderLine("Origin"); - - // FIXME: Read allowed clients from the profile instead; - $owner = StorageServer::getOwner(); - - $allowedClients = $owner['allowedClients'] ?? []; - $allowedOrigins = []; - foreach ($allowedClients as $clientId) { - $clientRegistration = ClientRegistration::getRegistration($clientId); - if (isset($clientRegistration['client_name'])) { - $allowedOrigins[] = $clientRegistration['client_name']; - } - if (isset($clientRegistration['origin'])) { - $allowedOrigins[] = $clientRegistration['origin']; - } - } - if ($origin =="") { - $allowedOrigins[] = "app://unset"; // FIXME: this should not be here. - $origin = "app://unset"; - } - - if (!$wac->isAllowed($rawRequest, $webId, $origin, $allowedOrigins)) { - $response = new Response(); - $response = $response->withStatus(403, "Access denied!"); - StorageServer::respond($response); - exit(); - } - - $response = $resourceServer->respondToRequest($rawRequest); - $response = $wac->addWACHeaders($rawRequest, $response, $webId); - StorageServer::respond($response);