diff --git a/.env b/.env index f68a743..20998a9 100644 --- a/.env +++ b/.env @@ -1,13 +1,56 @@ # All the values in this file can be overridden in a file named .env.local # NB: make sure that one is never stored in git + DB_DSN=sqlite:/var/www/VeraCrypt-CrashCollector/var/data/crashcollector.db DB_USER= DB_PASSWORD= +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= + +# When empty, the client's IP address will be taken from $_SERVER['REMOTE_ADDR']. +# Set it to a non empty string to have the client IP be extracted from a request HTTP header. +# Supported values: HTTP_CLIENT_IP, HTTP_FASTLY_CLIENT_IP, HTTP_TRUE_CLIENT_IP, HTTP_X_REAL_IP, HTTP_X_FORWARDED_FOR +# NB: HTTP_FASTLY_CLIENT_IP is not reliable by default, you have to set up dedicated vcl code for that, see https://www.fastly.com/documentation/reference/http/http-headers/Fastly-Client-IP/ +# NB: when setting it to a non empty value, TRUSTED_PROXIES has to be set as well (see below for details). +CLIENT_IP_HEADER= +# Csv list of IP addresses of proxies that you trust to set a truthful header identifying the client ip address. +# This means that the first proxy in the truthful chain _has to_ reset the designated http header if it receives it in +# its request. +# When a request comes in from an IP which is not in TRUSTED_PROXIES, $_SERVER['REMOTE_ADDR'] will be used as client IP +TRUSTED_PROXIES= + APP_DEBUG=false +# NB: should always have a trailing slash ROOT_URL=/ +# Used for links when sending password-reset emails +WEBSITE=https://crashcollector.veracrypt.fr + +# Used when sending password-reset emails +MAIL_FROM=crashcollector@veracrypt.fr + +# Set to true to make the app generate urls such as `/admin/` instead of `/admin/index.php`. +# NB: this requires matching webserver configuration, such as `index index.php` for Nginx +URLS_STRIP_INDEX_DOT_PHP=false +# Set to true to make the app generate urls such as `/report/upload` instead of `/report/upload.php`. +# NB: this requires matching webserver configuration, see f.e. +# https://serverfault.com/questions/761627/nginx-rewrite-to-remove-php-from-files-has-no-effect-but-to-redirect-to-homepag +URLS_STRIP_PHP_EXTENSION=false + +# Enable/disable the feature to allow users self-service password reset via being sent an email, aka. 'forgot password' +ENABLE_FORGOTPASSWORD=true +# Enable/disable the feature to allow uploading crash reports via a browser-based form instead of using API as VeraCrypt does +ENABLE_BROWSER_UPLOAD=false + +LOG_DIR=/var/www/VeraCrypt-CrashCollector/var/logs +# The audit log traces user events such as login, password changes, etc +AUDIT_LOG_FILE=audit.log +# see Psr\Log\LogLevel for valid values +AUDIT_LOG_LEVEL=info + # Algorithm can be set to '2y' (bcrypt), 'argon2i', 'argon2id', the latter 2 only if an appropriate extension is loaded. # If left unspecified, the php default algorithm will be used PWD_HASH_ALGORITHM= diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8b4a71..1e2dd71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,136 +1,148 @@ -# Contributing to VeraCrypt Crash Collector - -Thank you for considering contributing to VeraCrypt Crash Collector! Your contributions help improve the project, and we appreciate your effort. The following guidelines will assist you through the contribution process. - -## Getting Started - -### 1. Fork the Repository - -- Navigate to the [VeraCrypt-CrashCollector](https://github.com/veracrypt/VeraCrypt-CrashCollector) repository and click "Fork." -- Clone your fork locally: - ```bash - git clone https://github.com/your-username/VeraCrypt-CrashCollector.git - ``` -- Set up the upstream remote to keep your fork up-to-date with the original repository: - ```bash - git remote add upstream https://github.com/veracrypt/VeraCrypt-CrashCollector.git - ``` - -### 2. Set Up Your Development Environment - -Ensure you have the required tools installed to run a PHP web application. - -- **PHP**: Make sure you have PHP installed on your system. -- **Web Server**: Use a local web server like Apache or Nginx, or use the built-in PHP development server: - ```bash - php -S localhost:8000 - ``` - -### 3. Create a New Branch - -Before you start working, create a new branch for your changes: -```bash -git checkout -b feature/your-feature-name -``` - -Use a clear, descriptive name for your branch, such as `fix/issue-123` or `feature/new-feature`. - -### 4. Make Your Changes - -Make your changes in the new branch. Be sure to: - -- Follow the **coding standards** and existing conventions. -- Write **clear, concise comments** where necessary. -- Add or update **tests** if you are adding new functionality. -- Regularly run the project to ensure everything is working. - -### 5. Test Your Changes - -Run the application locally to ensure your changes work using your preferred PHP development setup. - -### 6. Commit Your Changes - -After making sure everything is working, commit your changes with a meaningful message: -```bash -git commit -m "Fix issue with crash report handling in macOS" -``` - -Try to keep your commits small and focused on a specific change. - -### 7. Push to Your Fork - -Push your changes to your fork on GitHub: -```bash -git push origin feature/your-feature-name -``` - -### 8. Create a Pull Request (PR) - -Once your changes are pushed, open a Pull Request (PR) in the original repository: - -1. Go to the [Pull Requests](https://github.com/veracrypt/VeraCrypt-CrashCollector/pulls) section. -2. Click "New Pull Request." -3. Choose your branch and provide a descriptive title and detailed description of your changes. - -Make sure to link to any relevant issues using `Fixes #issue_number` in the description. This will automatically close the linked issue when the PR is merged. - -## Code Reviews - -All PRs are subject to review by maintainers or other contributors. Please: - -- Be open to feedback. -- Address requested changes promptly. -- Participate in discussions if necessary. - -Reviewing ensures code quality, consistency, and alignment with project goals. Don't hesitate to ask for clarification if you're unsure about any feedback. - -## Contribution Guidelines - -### Bug Reports - -If you encounter a bug, please submit an issue to help us investigate: - -- **Title**: A concise description of the issue. -- **Steps to Reproduce**: A detailed list of steps to reproduce the bug. -- **Expected Behavior**: What should have happened. -- **Actual Behavior**: What actually happened, including error messages if applicable. -- **Versions**: The VeraCrypt version and the OS version (Linux/macOS) you are using. -- **Logs or Crash Reports**: Attach relevant logs or crash reports, if available. - -### Feature Requests - -We welcome new feature suggestions! If you have an idea, submit an issue labeled "feature request" with the following details: - -- **Use Case**: Why this feature is needed. -- **Proposed Solution**: A description of how it might work. -- **Alternatives Considered**: Other possible approaches (if applicable). - -### Coding Standards - -- Follow the **existing code style** and patterns. -- Always include **descriptive comments** in your code. -- Write **unit tests** for new features or bug fixes when applicable. -- Ensure your changes do not break existing functionality. - -### Commit Guidelines - -- Keep commits small and focused. -- Use descriptive commit messages, following this format: - - **fix**: for bug fixes. - - **feat**: for new features. - - **docs**: for documentation changes. - - **refactor**: for code improvements. - - **test**: for test changes or additions. - -Example commit message: -``` -feat: add crash report parsing for Linux -``` - -## License - -By contributing to VeraCrypt Crash Collector, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). - ---- - -Thank you for contributing! We look forward to collaborating with you. +# Contributing to VeraCrypt Crash Collector + +Thank you for considering contributing to VeraCrypt Crash Collector! Your contributions help improve the project, and we +appreciate your effort. The following guidelines will assist you through the contribution process. + +## Getting Started + +### 1. Fork the Repository + +- Navigate to the [VeraCrypt-CrashCollector](https://github.com/veracrypt/VeraCrypt-CrashCollector) repository and click "Fork." +- Clone your fork locally: + ```bash + git clone https://github.com/your-username/VeraCrypt-CrashCollector.git + ``` +- Set up the upstream remote to keep your fork up-to-date with the original repository: + ```bash + git remote add upstream https://github.com/veracrypt/VeraCrypt-CrashCollector.git + ``` + +### 2. Set Up Your Development Environment + +Ensure you have the required tools installed to run a PHP web application. + +- **PHP**: Make sure you have PHP installed on your system. +- **Redis**: Make sure you have a Redis server installed on your system or reachable from it +- **Web Server**: Use a local web server like Apache or Nginx, or use the built-in PHP development server: + ```bash + php -S localhost:8000 + ``` + +### 3. Create a New Branch + +Before you start working, create a new branch for your changes: +```bash +git checkout -b feature/your-feature-name +``` + +Use a clear, descriptive name for your branch, such as `fix/issue-123` or `feature/new-feature`. + +### 4. Make Your Changes + +Make your changes in the new branch. Be sure to: + +- Follow the **coding standards** and existing conventions. +- Write **clear, concise comments** where necessary. +- Add or update **tests** if you are adding new functionality. +- Regularly run the project to ensure everything is working. + +### 5. Test Your Changes + +Run the application locally to ensure your changes work using your preferred PHP development setup. + +### 6. Commit Your Changes + +After making sure everything is working, commit your changes with a meaningful message: +```bash +git commit -m "Fix issue with crash report handling in macOS" +``` + +Try to keep your commits small and focused on a specific change. + +### 7. Push to Your Fork + +Push your changes to your fork on GitHub: +```bash +git push origin feature/your-feature-name +``` + +### 8. Create a Pull Request (PR) + +Once your changes are pushed, open a Pull Request (PR) in the original repository: + +1. Go to the [Pull Requests](https://github.com/veracrypt/VeraCrypt-CrashCollector/pulls) section. +2. Click "New Pull Request." +3. Choose your branch and provide a descriptive title and detailed description of your changes. + +Make sure to link to any relevant issues using `Fixes #issue_number` in the description. This will automatically close the linked issue when the PR is merged. + +## Code Reviews + +All PRs are subject to review by maintainers or other contributors. Please: + +- Be open to feedback. +- Address requested changes promptly. +- Participate in discussions if necessary. + +Reviewing ensures code quality, consistency, and alignment with project goals. Don't hesitate to ask for clarification if you're unsure about any feedback. + +## Contribution Guidelines + +### Bug Reports + +If you encounter a bug, please submit an issue to help us investigate: + +- **Title**: A concise description of the issue. +- **Steps to Reproduce**: A detailed list of steps to reproduce the bug. +- **Expected Behavior**: What should have happened. +- **Actual Behavior**: What actually happened, including error messages if applicable. +- **Versions**: The VeraCrypt version and the OS version (Linux/macOS) you are using. +- **Logs or Crash Reports**: Attach relevant logs or crash reports, if available. + +### Feature Requests + +We welcome new feature suggestions! If you have an idea, submit an issue labeled "feature request" with the following details: + +- **Use Case**: Why this feature is needed. +- **Proposed Solution**: A description of how it might work. +- **Alternatives Considered**: Other possible approaches (if applicable). + +### Design Guidelines + +- Reduce external dependencies as much as possible. Ideally, this package should not depend on any external library + or service +- Security is paramount +- Use strict typing whenever possible +- + +### Coding Standards + +- Follow the **existing code style** and patterns. + Code formatting rules are specified in the `.editorconfig` file. + HTML styling is based on Bootstrap, version 5.3. +- Always include **descriptive comments** in your code. +- Write either **unit tests** or **functional tests** for new features or bug fixes when applicable. +- Ensure your changes do not break existing functionality. + +### Commit Guidelines + +- Keep commits small and focused. +- Use descriptive commit messages, following this format: + - **fix**: for bug fixes. + - **feat**: for new features. + - **docs**: for documentation changes. + - **refactor**: for code improvements. + - **test**: for test changes or additions. + +Example commit message: +``` +feat: add crash report parsing for Linux +``` + +## License + +By contributing to VeraCrypt Crash Collector, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). + +--- + +Thank you for contributing! We look forward to collaborating with you. diff --git a/README.md b/README.md index 477159a..56d8544 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,15 @@ ## Overview -**VeraCrypt Crash Collector** is a web application designed to gather and manage crash reports from the VeraCrypt desktop application running on Linux and macOS. This app ensures that users have control over their data, as crash reports are only sent if users explicitly allow it after VeraCrypt detects a crash has occurred. +**VeraCrypt Crash Collector** is a web application designed to gather and manage crash reports from the VeraCrypt desktop +application running on Linux and macOS. +This app ensures that users have control over their data, as crash reports are only sent if users explicitly allow it +after VeraCrypt detects a crash has occurred. -The collected crash reports provide vital information to improve the stability and performance of VeraCrypt by helping to identify and resolve issues. +The collected crash reports provide vital information to improve the stability and performance of VeraCrypt by helping +to identify and resolve issues. + +Similar projects are f.e. https://github.com/tdf/crash-srv. ## Crash Reporting Mechanism @@ -20,11 +26,13 @@ When a crash occurs, the following information is gathered by the crash reportin ### Important Note -No personal information is included in the crash reports. The call stack captured is purely technical and does not contain any user data. +No personal information is included in the crash reports. The call stack captured is purely technical and does not contain +any user data. ## Purpose -The goal of VeraCrypt Crash Collector is to streamline the crash report management process and provide a clear path to fixing any technical issues in VeraCrypt. It helps developers identify and resolve bugs by analyzing the crash data collected. +The goal of VeraCrypt Crash Collector is to streamline the crash report management process and provide a clear path to +fixing any technical issues in VeraCrypt. It helps developers identify and resolve bugs by analyzing the crash data collected. ## Contribution @@ -36,18 +44,83 @@ This project is licensed under the [Apache License 2.0](LICENSE). ## Requirements -- PHP 8.1 and up, with the SQLite extension +- PHP 8.1 and up, with the SQLite and PHPRedis extensions (using SQLite Library 3.35.4 or later) +- a webserver configured to run PHP +- a Redis server - Composer, to install the required dependencies +Note: PostgreSQL and MariaDB >= 10.5 should also work as an alternative to SQLite, but so far they have been tested less +extensively. + ## Installation 1. run `composer install` at the root of the project 2. check the configuration in `.env`, and, if required, change any value by saving it in a file named `.env.local` 3. make sure that the `var/data` directory is writeable (this is where the app will create its sqlite db by default), - as well as `var/cache/twig` -4. create an administrator user: run the cli command `./bin/console user:create --is-superuser ` -5. set up the webserver: + as well as `var/logs` and `var/cache/twig` +4. configure php: + + - for a production installation, it is recommended to follow the owasp guidelines available at + https://cheatsheetseries.owasp.org/cheatsheets/PHP_Configuration_Cheat_Sheet.html + - it is recommended to use Redis for php session storage instead of the default file-based storage +5. create an administrator user: run the cli command `php ./bin/console user:create --is-superuser ` +6. set up a cronjob (daily or weekly is fine) running the cli command `php ./bin/console token:prune` +7. set up the webserver: - configure the vhost root directory to be the `public` directory. No http access to any other folder please - make sure .php scripts are executed via the php interpreter + - the file to serve when a directory index is requested should be `index.php` - no rewrite rules are necessary +8. navigate to `https://your-host/report/upload.php` to upload crash reports; to `https://your-host/admin/index.php` for browsing them + +### Advanced configuration + +* Removing `.php` from the URLs used by the application + + In order to have the application use "php-less" URLs, you have to 1. set up the webserver so that it will try to + pass requests for URLs not ending in `.php` to the php interpreter, and 2. configure the application accordingly. + + Point 1 can be done, for Nginx, following f.e. the instructions at + https://serverfault.com/questions/761627/nginx-rewrite-to-remove-php-from-files-has-no-effect-but-to-redirect-to-homepag + + For point 2, add `URLS_STRIP_INDEX_DOT_PHP=true` and `URLS_STRIP_PHP_EXTENSION=true` to file `.env.local` + +* Optimizing SQLite performance and scalability + + Optionally, run the SQLite pragma `journal_mode=WAL` to have optimized performance and concurrency + + Optionally, set up cronjobs to run the SQLite pragmas `optimize` and `integrity_check` + +* Using Redis for PHP session storage + + Google is your friend - there are countless guides for this. + +## How it works + +Once uploaded, crash reports are stored in a SQLite database. They are not available for examination to the public, but +only to registered users of this web application. For a short time after the upload, the submitter of a crash report can +see the uploaded data and is given a chance to remove it if desired. + +The web interface is kept extremely simple by design. Besides supporting the anonymous upload of the crash reports, it +allows application users to browse them and to change their own login password. The only way to manage the application's +users accounts (create, remove, update, enable/disable them) is via a command-line script. + +### The upload API + +The interaction between VeraCrypt and the Crash Collector is the following: + +1. VeraCrypt sends a POST request to the url `/report/upload.php` using `application/x-www-form-urlencoded` encoding. + In case of success, a 303 redirection response is returned. + In case of errors with the POST request data, a 400 response is returned, with `plain/text` content tipe, and + error messages displayed one per line. + In case of unexpected / server errors, a 40x or 50x response can also be returned. +2. In case of a successful upload, VeraCrypt should start a browser session and send the user to the redirection target + URL given at step 1. + +Rate limiting is implemented, to avoid spamming of the upload page. + +### Troubleshooting and Debugging + +The name of the fields to submit at step 1 above can be seen by setting `ENABLE_BROWSER_UPLOAD=true` in config. file +`.env.local`, and pointing a browser at the `/report/upload.php` URL. +That results in the display of a crash-report upload form which can be filled in manually. diff --git a/bin/console b/bin/console index ad30bde..51412cb 100755 --- a/bin/console +++ b/bin/console @@ -14,6 +14,8 @@ use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; +use Veracrypt\CrashCollector\Repository\ForgotPasswordTokenRepository; +use Veracrypt\CrashCollector\Repository\ManageReportTokenRepository; use Veracrypt\CrashCollector\Repository\UserRepository; use Veracrypt\CrashCollector\Security\PasswordHasher; @@ -122,12 +124,15 @@ $application->register('user:update') ->addOption('is-superuser', null, InputOption::VALUE_REQUIRED, 'Use a 1/0 value to enable/disable') ->addOption('is-inactive', null, InputOption::VALUE_REQUIRED, 'Use a 1/0 value to enable/disable') ->setCode(function (InputInterface $input, OutputInterface $output): int { + $ph = new PasswordHasher(); $ur = new UserRepository(); $isSuperuser = $input->getOption('is-superuser') !== null ? (bool)$input->getOption('is-superuser') : null; $isActive = $input->getOption('is-inactive') !== null ? !$input->getOption('is-inactive') : null; + /// @todo move the hash creation to the Repository? + $passwordHash = $input->getOption('password') !== null ? $ph->hash($input->getOption('password')) : null; if (!$ur->updateUser( $input->getArgument('username'), - $input->getOption('password'), + $passwordHash, $input->getOption('email'), $input->getOption('first-name'), $input->getOption('last-name'), @@ -141,4 +146,14 @@ $application->register('user:update') return Command::SUCCESS; }); +$application->register('tokens:prune') + ->setCode(function (InputInterface $input, OutputInterface $output): int { + $repo = new ForgotPasswordTokenRepository(); + $repo->prune(); + $repo = new ManageReportTokenRepository(); + $repo->prune(); + $output->writeln("Expired tokens deleted: forgotpassword and managereport"); + return Command::SUCCESS; + }); + $application->run(); diff --git a/composer.json b/composer.json index d443985..a2acccd 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "require": { "php": "^8.1", "ext-pdo": "*", + "ext-redis": "*", "ext-sqlite3": "*", "psr/log": "^3.0.2", "symfony/console": "^6.4.12", diff --git a/composer.lock b/composer.lock index bc16ad9..ded6cd4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ca3b1234b2816b7420d368f80e4b9d91", + "content-hash": "2750a17591ea2553a97475b0a08c4eaa", "packages": [ { "name": "psr/container", @@ -916,14 +916,15 @@ "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": "^8.1", "ext-pdo": "*", + "ext-redis": "*", "ext-sqlite3": "*" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/public/admin/forgotpassword.php b/public/admin/forgotpassword.php new file mode 100644 index 0000000..0a4bb6c --- /dev/null +++ b/public/admin/forgotpassword.php @@ -0,0 +1,65 @@ +generate(__DIR__ . '/index.php'), true, 303); + exit(); +} + +// if non-anon user, redirect to resetpassword instead of using this form +$user = $firewall->getUser(); +if ($user->isAuthenticated()) { + header('Location: ' . $router->generate(__DIR__ . '/resetpassword.php'), true, 303); + exit(); +} + +$form = new ForgotPasswordForm($router->generate(__FILE__)); + +if ($form->isSubmitted()) { + $form->handleRequest(); + if ($form->isValid()) { + + /** @var User $user */ + $user = $form->getUser(); + + $ph = new PasswordHasher(); + $fptRepo = new ForgotPasswordTokenRepository(); + $secret = $ph->generateRandomString($fptRepo->tokenLength); + $token = $fptRepo->createToken($user->username, $ph->hash($secret)); + /// @todo use mime multipart to add an html version besides plain text + $mailer = new Mailer(); + $email = new Email(); + $form2 = new ForgotPasswordEmailForm(__DIR__ . '/setnewpassword.php', $token->id, $secret); + $text = $tpl->render('emails/forgotpassword.txt.twig', [ + 'link' => $_ENV['WEBSITE'] . $router->generate(__DIR__ . '/setnewpassword.php', $form2->getQueryStringParts()) + ]); + $email->from($_ENV['MAIL_FROM'])->to($user->email)->subject("VeraCrypt Crash Collector password reset link")->text($text); + try { + $mailer->send($email); + } catch (\RuntimeException $e) { + $form->setError('There was an error sending the email, please retry later.'); + } + } +} + +echo $tpl->render('admin/forgotpassword.html.twig', [ + 'form' => $form, + 'urls' => $firewall->getAdminUrls(), +]); diff --git a/public/admin/index.php b/public/admin/index.php index ffe6b4c..dec19df 100644 --- a/public/admin/index.php +++ b/public/admin/index.php @@ -2,15 +2,28 @@ require_once(__DIR__ . '/../../autoload.php'); +use Veracrypt\CrashCollector\Exception\AuthorizationException; use Veracrypt\CrashCollector\Form\CrashReportSearchForm; use Veracrypt\CrashCollector\Repository\CrashReportRepository; use Veracrypt\CrashCollector\Router; +use Veracrypt\CrashCollector\Security\Firewall; +use Veracrypt\CrashCollector\Security\UserRole; use Veracrypt\CrashCollector\Templating; /// @todo get from .env? $pageSizes = [10, 25, 50]; -/// @todo add ACLs +$firewall = Firewall::getInstance(); +$router = new Router(); +$tpl = new Templating(); + +try { + $firewall->require(UserRole::User); + $user = $firewall->getUser(); +} catch (AuthorizationException $e) { + $firewall->displayAdminLoginPage($router->generate(__FILE__, $_GET)); + exit(); +} $reports = []; $numReports = 0; @@ -25,7 +38,7 @@ $pageNum = 0; } -$form = new CrashReportSearchForm(); +$form = new CrashReportSearchForm($router->generate(__FILE__)); if ($form->isSubmitted()) { $form->handleRequest(); if ($form->isValid()) { @@ -37,10 +50,10 @@ } } -$tpl = new Templating(); -$router = new Router(); echo $tpl->render('admin/index.html.twig', [ - 'form' => $form, 'reports' => $reports, 'num_reports' => $numReports, 'current_page' => $pageNum, + 'user' => $user, 'form' => $form, 'reports' => $reports, 'num_reports' => $numReports, 'current_page' => $pageNum, 'page_size' => $pageSize, 'num_pages' => ceil($numReports / $pageSize), 'page_sizes' => $pageSizes, - 'form_url' => $router->generate(__FILE__, $form->getQueryStringParts(true)), + 'urls' => array_merge($firewall->getAdminUrls(), [ + 'paginator' => $router->generate(__FILE__, $form->getQueryStringParts(true)) + ]), ]); diff --git a/public/admin/login.php b/public/admin/login.php new file mode 100644 index 0000000..4e55058 --- /dev/null +++ b/public/admin/login.php @@ -0,0 +1,42 @@ +generate(__FILE__), $router->generate(__DIR__ . '/index.php')); + +if ($form->isSubmitted()) { + $form->handleRequest(); + if ($form->isValid()) { + $data = $form->getData(); + // the redirect url is validated by the form to match a local file within the web root + $redirectUrl = $data['redirect']; + unset($data['redirect']); + $authenticator = new UsernamePasswordAuthenticator(); + try { + $authenticator->authenticate(...$data); + header('Location: ' . $redirectUrl, true, 303); + $form->onSuccessfulLogin(); + exit(); + } catch (AuthenticationException $e) { + /// @todo should we reduce the level of info shown? Eg. not tell apart unknown user from bad password + $form->setError($e->getMessage()); + } + } +} else { + /// @todo should we give some info or warning if the user is logged in already? +} + +$firewall = Firewall::getInstance(); +$tpl = new Templating(); +echo $tpl->render('admin/login.html.twig', [ + 'form' => $form, + 'urls' => $firewall->getAdminUrls(), +]); diff --git a/public/admin/logout.php b/public/admin/logout.php new file mode 100644 index 0000000..60bcc9f --- /dev/null +++ b/public/admin/logout.php @@ -0,0 +1,13 @@ +logoutUser(true); + +$router = new Router(); +// Note: unlike 301, 303 responses are not cacheable by default, unless accompanied by cache-control headers, as +// specified in https://datatracker.ietf.org/doc/html/rfc7231#section-6.1 +header('Location: ' . $router->generate(__DIR__ . '/index.php'), true, 303); diff --git a/public/admin/resetpassword.php b/public/admin/resetpassword.php new file mode 100644 index 0000000..c10b083 --- /dev/null +++ b/public/admin/resetpassword.php @@ -0,0 +1,40 @@ +require(UserRole::User); + $user = $firewall->getUser(); +} catch (AuthorizationException $e) { + $firewall->displayAdminLoginPage($router->generate(__FILE__)); + exit(); +} + +$form = new ResetPasswordForm($router->generate(__FILE__), $user); +if ($form->isSubmitted()) { + $form->handleRequest(); + if ($form->isValid()) { + $passwordHasher = new PasswordHasher(); + $repository = new UserRepository(); + $repository->updateUser($user->getUserIdentifier(), $passwordHasher->hash($form->getFieldData('newPassword'))); + } +} + +echo $tpl->render('admin/resetpassword.html.twig', [ + 'user' => $user, + 'form' => $form, + 'urls' => $firewall->getAdminUrls(), +]); diff --git a/public/admin/setnewpassword.php b/public/admin/setnewpassword.php new file mode 100644 index 0000000..fa87a49 --- /dev/null +++ b/public/admin/setnewpassword.php @@ -0,0 +1,74 @@ +generate(__DIR__ . '/index.php'), true, 303); + exit(); +} + +// if non-anon user, redirect to resetpassword instead of using this form +$currentUser = $firewall->getUser(); +if ($currentUser->isAuthenticated()) { + header('Location: ' . $router->generate(__DIR__ . '/resetpassword.php'), true, 303); + exit(); +} + +// as per owasp recommendations - https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html#url-tokens +header('Referrer-Policy: no-referrer'); + +$errorMessage = null; +$tokenId = null; +$secret = null; + +$form1 = new ForgotPasswordEmailForm($router->generate(__FILE__)); +if ($form1->isSubmitted()) { + $form1->handleRequest(); + if ($form1->isValid()) { + $tokenId = $form1->getFieldData('token'); + $secret = $form1->getFieldData('secret'); + } else { + $errorMessage = $form1->errorMessage; + } +} + +$form2 = new SetNewPasswordForm($router->generate(__FILE__), $tokenId, $secret); +if ($errorMessage === null && $form2->isSubmitted()) { + $form2->handleRequest(); + if ($form2->isValid()) { + // update the user + $user = $form2->getUser(); + $passwordHasher = new PasswordHasher(); + $repository = new UserRepository(); + $repository->updateUser($user->username, $passwordHasher->hash($form2->getFieldData('newPassword'))); + // 'consume' (remove) the token + $form2->getTokenRepository()->delete($form2->getFieldData('token')); + } +} + +if (!$form1->isSubmitted() && !$form2->isSubmitted()) { + $errorMessage = 'Nothing to see here, move along.'; + + $logger = Logger::getInstance('audit'); + $logger->debug("A request for setnewpassword.php was received, with no form submitted. Hacking attempt?"); +} + +$tpl = new Templating(); +echo $tpl->render('admin/setnewpassword.html.twig', [ + 'error' => $errorMessage, + 'form' => $form2, + 'urls' => $firewall->getAdminUrls(), +]); diff --git a/public/report/confirm.php b/public/report/confirm.php new file mode 100644 index 0000000..33c1239 --- /dev/null +++ b/public/report/confirm.php @@ -0,0 +1,61 @@ +generate(__FILE__)); +if ($form1->isSubmitted()) { + $form1->handleRequest(); + if ($form1->isValid()) { + $report = $form1->getReport(); + $tokenId = $form1->getFieldData('token'); + $secret = $form1->getFieldData('secret'); + } else { + $errorMessage = $form1->errorMessage; + } +} + +$form2 = new CrashReportRemoveForm($router->generate(__FILE__), $tokenId, $secret, $report); +if ($errorMessage === null && $form2->isSubmitted()) { + $form2->handleRequest(); + if ($form2->isValid()) { + // delete the report + $report = $form2->getReport(); + $repository = new CrashReportRepository(); + $repository->deleteReport($report->id); + // 'consume' (remove) the token + $form2->getTokenRepository()->delete($form2->getFieldData('token')); + } +} + +if (!$form1->isSubmitted() && !$form2->isSubmitted()) { + $errorMessage = 'Nothing to see here, move along.'; + + $logger = Logger::getInstance('audit'); + $logger->debug("A request for /report/confirm.php was received, with no form submitted. Hacking attempt?"); +} + +$tpl = new Templating(); +echo $tpl->render('report/confirm.html.twig', [ + 'error' => $errorMessage, + 'report' => $report, + 'form' => $form2, + 'urls' => [ + 'root' => $router->generate(__DIR__ . '/..'), + ], +]); diff --git a/public/report/upload.php b/public/report/upload.php new file mode 100644 index 0000000..6619576 --- /dev/null +++ b/public/report/upload.php @@ -0,0 +1,69 @@ +generate(__FILE__)); +$confirmUrl = null; + +if ($form->isSubmitted()) { + $form->handleRequest(); + if ($form->isValid()) { + /// @todo catch db runtime errors and show a nice error msg such as 'try later' - possibly distinguishing + /// data-related errors and using an appropriate error message + $crr = new CrashReportRepository(); + $report = $crr->createReport(...$form->getData()); + + $mrr = new ManageReportTokenRepository(); + $ph = new PasswordHasher(); + $secret = $ph->generateRandomString($mrr->tokenLength); + $token = $mrr->createToken($report->id, $ph->hash($secret)); + + $confirmUrl = $router->generate(__DIR__ . '/confirm.php', ['tkn' => $token->id, 'sec' => $secret]); + header('Location: ' . $confirmUrl, true, 303); + exit(); + } else { + http_response_code(400); + header('Content-Type: text/plain'); + + $errors = $form->getFieldsErrors(); + array_walk($errors, function(&$value, $key) use ($form) { + $value = $form->getField($key)->label . ': ' . $value; + }); + if ($form->errorMessage != '') { + array_unshift($errors, $form->errorMessage); + } + echo implode("\n", $errors); + + exit(); + } +} + +if (!EnvVarProcessor::bool($_ENV['ENABLE_BROWSER_UPLOAD'])) { + http_response_code(404); + exit(); +} + +// uncomment these lines to allow to pre-fill form fields using a GET request, but only act on POST +//if ($form->isSubmitted($_GET)) { +// $form->handleRequest($_GET); +//} + +$tpl = new Templating(); +echo $tpl->render('report/upload.html.twig', [ + 'form' => $form, + 'urls' => [ + 'root' => $router->generate(__DIR__ . '/..'), + 'confirm' => $confirmUrl + ], +]); diff --git a/public/upload/index.php b/public/upload/index.php deleted file mode 100644 index b0e6d49..0000000 --- a/public/upload/index.php +++ /dev/null @@ -1,29 +0,0 @@ -isSubmitted()) { - $form->handleRequest(); - if ($form->isValid()) { - /// @todo catch db runtime errors and show a nice error msg such as 'try later' - possibly distinguishing - /// data-related errors and using an appropriate error message - $crr = new CrashReportRepository(); - $crr->createReport(...$form->getData()); - } -} elseif ($form->isSubmitted($_GET)) { - $form->handleRequest($_GET); -} - -$tpl = new Templating(); -$router = new Router(); -echo $tpl->render('upload/index.html.twig', [ - 'form' => $form, 'form_url' => $router->generate(__FILE__) -]); diff --git a/resources/templates/admin/forgotpassword.html.twig b/resources/templates/admin/forgotpassword.html.twig new file mode 100644 index 0000000..1b69be9 --- /dev/null +++ b/resources/templates/admin/forgotpassword.html.twig @@ -0,0 +1,23 @@ +{% extends "base.html.twig" %} + +{% block pagetitle %}VeraCrypt Crash Collector | Forgot Password{% endblock %} + +{% block content %} + {% import "parts/forms/macros.html.twig" as forms %} + {% if form.isSubmitted() and (form.isValid() or form.pretendIsValid()) %} +

Thanks. If the email provided matches an active user account, an email has been sent to it.

+ {% else %} + {# @todo make the form a bit less wide #} +
+ {% if form.errorMessage|default('') != '' %} +

{{ form.errorMessage }}

+ {% endif %} +
+ {{ forms.input(form.getField('email'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}) }} +
+
+ {{ forms.input(form.getSubmit(), {'input': 'btn btn-primary form-control'}) }} +
+
+ {% endif %} +{% endblock %} diff --git a/resources/templates/admin/index.html.twig b/resources/templates/admin/index.html.twig index 5f039e7..23fc312 100644 --- a/resources/templates/admin/index.html.twig +++ b/resources/templates/admin/index.html.twig @@ -1,10 +1,14 @@ {% extends "base.html.twig" %} -{% block body %} +{% block pagetitle %}VeraCrypt Crash Collector | Search{% endblock %} + +{% block content %} {% import "parts/forms/macros.html.twig" as forms %}
-

VeraCrypt Crash Reporter search

-
+ + {% if form.errorMessage|default('') != '' %} +

{{ form.errorMessage }}

+ {% endif %} {{ include('parts/forms/cr_common_fields.html.twig') }}
{{ forms.input(form.getField('minDate'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-4', 'input': 'form-control'}) }} @@ -30,7 +34,7 @@
{% if form.isSubmitted() and form.isValid() %}

Found {{ num_reports }} crash reports{% if num_reports > page_size %}, showing {{ page_size }} per page{% endif %}

-{{ forms.paginator(current_page, num_pages, form_url) }} +{{ forms.paginator(current_page, num_pages, urls.paginator) }} @@ -61,7 +65,7 @@ {% endfor %}
-{{ forms.paginator(current_page, num_pages, form_url) }} +{{ forms.paginator(current_page, num_pages, urls.paginator) }} {% else %} Note: accepted wildcard characters are the ones for SQL LIKE statements: '_' for any one char, and '?' for zero or more chars diff --git a/resources/templates/admin/login.html.twig b/resources/templates/admin/login.html.twig new file mode 100644 index 0000000..753477b --- /dev/null +++ b/resources/templates/admin/login.html.twig @@ -0,0 +1,24 @@ +{% extends "base.html.twig" %} + +{% block content %} +{% import "parts/forms/macros.html.twig" as forms %} +{# @todo make the form a bit less wide #} + + {% if form.errorMessage|default('') != '' %} +

{{ form.errorMessage }}

+ {% endif %} +
+ {{ forms.input(form.getField('username'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}, {'spellcheck': 'false'}) }} +
+
+ {{ forms.input(form.getField('password'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}) }} +
+ {{ forms.input(form.getField('redirect'), {}) }} +
+ {{ forms.input(form.getSubmit(), {'input': 'btn btn-primary form-control'}) }} +
+ +{% if urls.forgotpassword is defined %} +Password forgotten? +{% endif %} +{% endblock %} diff --git a/resources/templates/admin/resetpassword.html.twig b/resources/templates/admin/resetpassword.html.twig new file mode 100644 index 0000000..737ad89 --- /dev/null +++ b/resources/templates/admin/resetpassword.html.twig @@ -0,0 +1,32 @@ +{% extends "base.html.twig" %} + +{% block pagetitle %}VeraCrypt Crash Collector | Reset Password{% endblock %} + +{% block content %} +{% import "parts/forms/macros.html.twig" as forms %} +{% if form.isSubmitted() and form.isValid() %} +

The password has been updated.

+{% else %} + {# @todo make the form a bit less wide #} +
+ {% if form.errorMessage|default('') != '' %} +

{{ form.errorMessage }}

+ {% endif %} +
+ {{ forms.input(form.getField('oldPassword'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}) }} +
+
+ {{ forms.input(form.getField('newPassword'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}) }} +
+
+ {{ forms.input(form.getField('newPasswordConfirm'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}) }} +
+
+ {# note: we could make the form transparently inject the antiCSRF input, to help devs not forget displaying it... + but then it would be easy to find out that the form never submits succesfully ;-) #} + {{ forms.input(form.getField('antiCSRF')) }} + {{ forms.input(form.getSubmit(), {'input': 'btn btn-primary form-control'}) }} +
+
+{% endif %} +{% endblock %} diff --git a/resources/templates/admin/setnewpassword.html.twig b/resources/templates/admin/setnewpassword.html.twig new file mode 100644 index 0000000..8a464a6 --- /dev/null +++ b/resources/templates/admin/setnewpassword.html.twig @@ -0,0 +1,31 @@ +{% extends "base.html.twig" %} + +{% block pagetitle %}VeraCrypt Crash Collector | Set New Password{% endblock %} + +{% block content %} + {% import "parts/forms/macros.html.twig" as forms %} + {% if error != '' %} +

{{ error }}

+ {% elseif form.isSubmitted() and form.isValid() %} +

The password has been updated. Please log in.

+ {% else %} + {# @todo make the form a bit less wide #} +
+ {% if form.errorMessage|default('') != '' %} +

{{ form.errorMessage }}

+ {% endif %} +
+ {{ forms.input(form.getField('newPassword'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}) }} +
+
+ {{ forms.input(form.getField('newPasswordConfirm'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}) }} +
+
+ {{ forms.input(form.getField('token')) }} + {{ forms.input(form.getField('secret')) }} + {#{ forms.input(form.getField('antiCSRF')) }#} + {{ forms.input(form.getSubmit(), {'input': 'btn btn-primary form-control'}) }} +
+
+ {% endif %} +{% endblock %} diff --git a/resources/templates/base.html.twig b/resources/templates/base.html.twig index a466131..78d9cc0 100644 --- a/resources/templates/base.html.twig +++ b/resources/templates/base.html.twig @@ -4,7 +4,7 @@ {% block head %} - VeraCrypt Crash Collector + {% block pagetitle %}VeraCrypt Crash Collector{% endblock %} {% endblock %} @@ -13,7 +13,15 @@ {# no js needed yet #} -{% block body %} +
+{% block navbar %} + {% endblock %} +{% block content %} +{% endblock %} +
diff --git a/resources/templates/emails/forgotpassword.txt.twig b/resources/templates/emails/forgotpassword.txt.twig new file mode 100644 index 0000000..eadcbf1 --- /dev/null +++ b/resources/templates/emails/forgotpassword.txt.twig @@ -0,0 +1,7 @@ +Please click on the following link to set up a new password: + +{{ link }} + +Need further assistance? Contact us at ... + +The VeraCrypt Crash Collector Admin Team diff --git a/resources/templates/parts/forms/cr_common_fields.html.twig b/resources/templates/parts/forms/cr_common_fields.html.twig index 93480af..0729e71 100644 --- a/resources/templates/parts/forms/cr_common_fields.html.twig +++ b/resources/templates/parts/forms/cr_common_fields.html.twig @@ -2,6 +2,6 @@ {% for field_name in ['programVersion', 'osVersion', 'hwArchitecture', 'executableChecksum', 'errorCategory', 'errorAddress'] %}
- {{ forms.input(form.getField(field_name), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}) }} + {{ forms.input(form.getField(field_name), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}, {'spellcheck': 'false'}) }}
{% endfor %} diff --git a/resources/templates/parts/forms/macros.html.twig b/resources/templates/parts/forms/macros.html.twig index dbfa5b0..ea41cc8 100644 --- a/resources/templates/parts/forms/macros.html.twig +++ b/resources/templates/parts/forms/macros.html.twig @@ -1,21 +1,45 @@ -{% macro input(field, css_classes=[]) %} - {# @todo allow better styling of the error message: align it below the input field #} - {% if field.inputType == 'datetime' %} +{% macro input(field, css_classes=[], input_attributes=[]) %} + {# @todo allow better styling of the error message: align it below the input field instead of the label #} + {% if field.inputType == 'anticsrf' %} + + + {% elseif field.inputType == 'datetime-local' %} + +
+ {% if field.errorMessage != '' %}

{{ field.errorMessage }}

{% endif %} + + {% elseif field.inputType == 'email' %} -
+
{% if field.errorMessage != '' %}

{{ field.errorMessage }}

{% endif %} + {% elseif field.inputType == 'hidden' %} - - {% elseif field.inputType == 'submit' %} -
+ + + {% elseif field.inputType == 'submit-button' %} +
+ + {% elseif field.inputType == 'password' %} + +
+ {% if field.errorMessage != '' %}

{{ field.errorMessage }}

{% endif %} + + {% elseif field.inputType == 'ratelimiter' %} + {# show nothing #} + {% elseif field.inputType == 'text' %} -
+
{% if field.errorMessage != '' %}

{{ field.errorMessage }}

{% endif %} + {% elseif field.inputType == 'textarea' %} -
+
{% if field.errorMessage != '' %}

{{ field.errorMessage }}

{% endif %} + + {% else %} + {# @todo we should really throw a \DomainException... #} +
ERROR! unsupported form field type: '{{ field.inputType }}'
{% endif %} {% endmacro %} diff --git a/resources/templates/report/confirm.html.twig b/resources/templates/report/confirm.html.twig new file mode 100644 index 0000000..2d3d0b0 --- /dev/null +++ b/resources/templates/report/confirm.html.twig @@ -0,0 +1,37 @@ +{% extends "base.html.twig" %} + +{% block pagetitle %}VeraCrypt Crash Collector | Confirm Report{% endblock %} + +{% block content %} + {% import "parts/forms/macros.html.twig" as forms %} + {% if error != '' %} +

{{ error }}

+ {% elseif form.isSubmitted() and form.isValid() %} +

The crash report has been deleted

+ {% else %} +
+

This is the Crash Report that you have just uploaded:

+

NB please use the Report Id for all future communications

+ {% if form.errorMessage|default('') != '' %} +

{{ form.errorMessage }}

+ {% endif %} +
+ {{ forms.input(form.getField('id'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}, {'spellcheck': 'false'}) }} +
+
+ {{ forms.input(form.getField('reported'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}, {'spellcheck': 'false'}) }} +
+ {{ include('parts/forms/cr_common_fields.html.twig') }} +
+ {{ forms.input(form.getField('callStack'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}, {'spellcheck': 'false'}) }} +
+
+ {{ forms.input(form.getField('token')) }} + {{ forms.input(form.getField('secret')) }} +
If you are not happy with it, you can delete it now. Otherwise, there is nothing else that you need to do.
+ {{ forms.input(form.getSubmit(), {'input': 'btn btn-primary form-control', 'div': 'col-sm-4'}) }} +
+
+ {% endif %} + +{% endblock %} diff --git a/resources/templates/upload/index.html.twig b/resources/templates/report/upload.html.twig similarity index 52% rename from resources/templates/upload/index.html.twig rename to resources/templates/report/upload.html.twig index b76b208..4d7948e 100644 --- a/resources/templates/upload/index.html.twig +++ b/resources/templates/report/upload.html.twig @@ -1,21 +1,25 @@ {% extends "base.html.twig" %} -{% block body %} +{% block pagetitle %}VeraCrypt Crash Collector | Upload Report{% endblock %} + +{% block content %} {% import "parts/forms/macros.html.twig" as forms %} -
-

VeraCrypt Crash Reporter

{% if form.isSubmitted() and form.isValid() %} -

Thank you for taking the time to submit the information and sorry for your hassles.

+

Thank you for taking the time to submit the information and sorry for your hassles.
+ You can see, and remove, your submission following this link, within the next hour. +

{% else %} -
+ + {% if form.errorMessage|default('') != '' %} +

{{ form.errorMessage }}

+ {% endif %} {{ include('parts/forms/cr_common_fields.html.twig') }}
- {{ forms.input(form.getField('callStack'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}) }} + {{ forms.input(form.getField('callStack'), {'label': 'col-sm-2 col-form-label', 'div': 'col-sm-10', 'input': 'form-control'}, {'spellcheck': 'false'}) }}
{{ forms.input(form.getSubmit(), {'input': 'btn btn-primary form-control'}) }}
{% endif %} -
{% endblock %} diff --git a/src/DotEnvLoader.php b/src/DotEnvLoader.php index 2773438..87c4bd9 100644 --- a/src/DotEnvLoader.php +++ b/src/DotEnvLoader.php @@ -7,7 +7,7 @@ class DotEnvLoader { /// @see https://pubs.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap08.html - protected static $VARNAME_REGEX = '/^(?:export[ \t]+)?([a-zA-Z_][a-zA-Z0-9_]*)/'; + protected static string $VARNAME_REGEX = '/^(?:export[ \t]+)?([a-zA-Z_][a-zA-Z0-9_]*)/'; /** * Loads values from a .env file and the corresponding .env.local file if they exist. @@ -108,7 +108,8 @@ protected function populate(array $values, bool $overrideExistingVars = true): v } $_ENV[$name] = $value; - /// @todo should we log a warning in case of a .env var starting with HTTP_ ? + /// @todo should we log a warning in case of a .env var starting with HTTP_ ? Note hat the logger class + /// depends on the dotenv config having been set up already... if (!str_starts_with($name, 'HTTP_')) { $_SERVER[$name] = $value; } diff --git a/src/Entity/CrashReport.php b/src/Entity/CrashReport.php index 9fdb5f6..5e3ce48 100644 --- a/src/Entity/CrashReport.php +++ b/src/Entity/CrashReport.php @@ -15,6 +15,7 @@ class CrashReport private int $dateReported; public function __construct( + public readonly ?int $id, int|DateTimeInterface $dateReported, public readonly string $programVersion, public readonly string $osVersion, diff --git a/src/Entity/ForgotPasswordToken.php b/src/Entity/ForgotPasswordToken.php new file mode 100644 index 0000000..761871e --- /dev/null +++ b/src/Entity/ForgotPasswordToken.php @@ -0,0 +1,7 @@ +fetchReport($this->reportId); + } +} diff --git a/src/Entity/Token.php b/src/Entity/Token.php new file mode 100644 index 0000000..ab73fc4 --- /dev/null +++ b/src/Entity/Token.php @@ -0,0 +1,62 @@ +dateCreated = $dateCreated; + } else { + $this->dateCreated = $dateCreated->getTimestamp(); + } + if (null === $expirationDate || is_int($expirationDate)) { + $this->expirationDate = $expirationDate; + } else { + $this->expirationDate = $expirationDate->getTimestamp(); + } + } + + public function __get($name) + { + switch ($name) { + case 'dateCreated': + return $this->dateCreated; + case 'dateCreatedDT': + return new DateTimeImmutable("@{$this->dateCreated}"); + case 'expirationDate': + return $this->expirationDate; + case 'expirationDateDT': + return $this->expirationDate === null ? null : new DateTimeImmutable("@{$this->expirationDate}"); + default: + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); + trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . + $trace[0]['line'], E_USER_ERROR); + } + } + + public function __isset($name) + { + switch ($name) { + case 'dateCreated': + case 'dateCreatedDT': + return true; + case 'expirationDate': + case 'expirationDateDT': + return isset($this->expirationDate); + default: + return false; + } + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 77428c7..0b055a2 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -4,6 +4,8 @@ use DateTimeImmutable; use DateTimeInterface; +use Veracrypt\CrashCollector\Security\UserInterface; +use Veracrypt\CrashCollector\Security\UserRole; /** * @property-read int $dateJoined @@ -11,7 +13,7 @@ * @property-read int|null $lastLogin * @property-read DateTimeImmutable|null $lastLoginDT */ -class User +class User implements UserInterface { // we store timestamps as properties instead of datetimes in order to be able to use PDO automatic hydration to object private int $dateJoined; @@ -41,6 +43,30 @@ public function __construct( } } + public function getRoles(): array + { + $roles = [UserRole::User]; + if ($this->isSuperuser) { + $roles[] = UserRole::Admin; + } + return $roles; + } + + public function getUserIdentifier(): string + { + return $this->username; + } + + public function isAuthenticated(): bool + { + return true; + } + + public function isActive(): bool + { + return $this->isActive; + } + public function __get($name) { switch ($name) { diff --git a/src/Entity/UserToken.php b/src/Entity/UserToken.php new file mode 100644 index 0000000..66ff342 --- /dev/null +++ b/src/Entity/UserToken.php @@ -0,0 +1,26 @@ +fetchUser($this->username); + } +} diff --git a/src/EnvVarProcessor.php b/src/EnvVarProcessor.php new file mode 100644 index 0000000..6754467 --- /dev/null +++ b/src/EnvVarProcessor.php @@ -0,0 +1,14 @@ +fields = [ + parent::__construct($actionUrl); + $this->fields = $this->getFieldsDefinitions($actionUrl, $report); + } + + protected function getFieldsDefinitions(string $actionUrl, ?CrashReport $report = null): array + { + return [ /// @todo get the field lengths from the Repo fields - 'programVersion' => new Field('Program version', 'pv', 'text', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255]), - 'osVersion' => new Field('OS version', 'ov', 'text', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255]), - 'hwArchitecture' => new Field('Architecture', 'ha', 'text', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255]), - 'executableChecksum' => new Field('Executable checksum', 'ck', 'text', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255]), - 'errorCategory' => new Field('Error category', 'ec', 'text', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255]), - 'errorAddress' => new Field('Error address', 'ea', 'text', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255]), + 'programVersion' => new Field\Text('Program version', 'pv', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255], $report?->programVersion , $this->isReadOnly), + 'osVersion' => new Field\Text('OS version', 'ov', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255], $report?->osVersion, $this->isReadOnly), + 'hwArchitecture' => new Field\Text('Architecture', 'ha', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255], $report?->hwArchitecture, $this->isReadOnly), + 'executableChecksum' => new Field\Text('Executable checksum', 'ck', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255], $report?->executableChecksum, $this->isReadOnly), + 'errorCategory' => new Field\Text('Error category', 'ec', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255], $report?->errorCategory, $this->isReadOnly), + 'errorAddress' => new Field\Text('Error address', 'ea', [FC::Required => $this->requireAllFieldsByDefault, FC::MaxLength => 255], $report?->errorAddress, $this->isReadOnly), ]; } } diff --git a/src/Form/CrashReportConfirmForm.php b/src/Form/CrashReportConfirmForm.php new file mode 100644 index 0000000..f5ce242 --- /dev/null +++ b/src/Form/CrashReportConfirmForm.php @@ -0,0 +1,68 @@ +fields = $this->getFieldsDefinitions($actionUrl, $report, $tokenId, $secret); + } + + protected function getFieldsDefinitions(string $actionUrl, ?CrashReport $report = null, ?int $tokenId = null, #[\SensitiveParameter] ?string $secret = null): array + { + $this->reportConstraint = new ReportTokenConstraint(ManageReportTokenRepository::class); + return [ + 'token' => new Field\Hidden('tkn', [ + /// @todo add an is-integer constraint? + FC::Required => true, + FC::RateLimit => new RateLimiter([ + new FixedWindow($actionUrl, 10, 300), // equivalent to once every 30 secs + new FixedWindow($actionUrl, 12, 3600), // equivalent to once every 5 minutes + new FixedWindow($actionUrl, 120, 86400), // equivalent to once every 12 minutes + ]), + FC::Custom => $this->reportConstraint + ], $tokenId), + /// @todo get the field length from the TokenRepository + 'secret' => new Field\Hidden('sec', [ + FC::Required => true, + FC::MinLength => 64, + FC::MaxLength => 64, + ], $secret), + ]; + } + + protected function validateSubmit(?array $request = null): void + { + // use the same error message used for invalid token-ids + if (!$this->reportConstraint->validateHash($this->getFieldData('secret'))) { + $this->setError("Token not found"); + } + } + + public function isSubmitted(?array $request = null): bool + { + if ($request === null) { + $request = $this->getRequest(); + } + return array_key_exists('tkn', $request); + } + + public function getReport(): null|CrashReport + { + return $this->reportConstraint->getReport(); + } +} diff --git a/src/Form/CrashReportRemoveForm.php b/src/Form/CrashReportRemoveForm.php new file mode 100644 index 0000000..c56986d --- /dev/null +++ b/src/Form/CrashReportRemoveForm.php @@ -0,0 +1,41 @@ + new Field\Text('Report Id', 'id', [FC::Required => $this->requireAllFieldsByDefault], $report?->id, $this->isReadOnly), + 'reported' => new Field\Text('Date', 'dt', [FC::Required => $this->requireAllFieldsByDefault], $report ? date('Y-m-d H:i:s', $report->dateReported) : null, $this->isReadOnly), + ], + CrashReportBaseForm::getFieldsDefinitions($actionUrl, $report), + [ + 'callStack' => new Field\TextArea('Call stack', 'cs', [FC::Required => $this->requireAllFieldsByDefault], $report?->callStack, $this->isReadOnly), + ], + parent::getFieldsDefinitions($actionUrl, $report, $tokenId, $secret), + ); + } + + public function isSubmitted(?array $request = null): bool + { + return CrashReportBaseForm::isSubmitted($request); + } + + public function getTokenRepository(): ReportTokenRepository + { + return $this->reportConstraint->getTokenRepository(); + } +} diff --git a/src/Form/CrashReportSearchForm.php b/src/Form/CrashReportSearchForm.php index bb4f463..b3ce35b 100644 --- a/src/Form/CrashReportSearchForm.php +++ b/src/Form/CrashReportSearchForm.php @@ -2,17 +2,24 @@ namespace Veracrypt\CrashCollector\Form; -use Veracrypt\CrashCollector\Form\FieldConstraint as FC; +use Veracrypt\CrashCollector\Entity\CrashReport; +/** + * @todo we could implement custom validation checks in handleRequest + */ class CrashReportSearchForm extends CrashReportBaseForm { protected string $submitLabel = 'Search'; protected int $submitOn = self::ON_GET; - public function __construct() + protected function getFieldsDefinitions(string $actionUrl, ?CrashReport $report = null): array { - parent::__construct(); - $this->fields['minDate'] = new Field('After', 'da', 'datetime'); - $this->fields['maxDate'] = new Field('Before', 'db', 'datetime'); + return array_merge( + parent::getFieldsDefinitions($actionUrl, $report), + [ + 'minDate' => new Field\DateTime('After', 'da'), + 'maxDate' => new Field\DateTime('Before', 'db'), + ] + ); } } diff --git a/src/Form/CrashReportSubmitForm.php b/src/Form/CrashReportSubmitForm.php index b2b8fff..c2341b8 100644 --- a/src/Form/CrashReportSubmitForm.php +++ b/src/Form/CrashReportSubmitForm.php @@ -2,15 +2,26 @@ namespace Veracrypt\CrashCollector\Form; +use Veracrypt\CrashCollector\Entity\CrashReport; use Veracrypt\CrashCollector\Form\FieldConstraint as FC; +use Veracrypt\CrashCollector\RateLimiter\Constraint\FixedWindow; class CrashReportSubmitForm extends CrashReportBaseForm { protected bool $requireAllFieldsByDefault = true; - public function __construct() + protected function getFieldsDefinitions(string $actionUrl, ?CrashReport $report = null): array { - parent::__construct(); - $this->fields['callStack'] = new Field('Call stack', 'cs', 'textarea', [FC::Required => $this->requireAllFieldsByDefault]); + return array_merge( + parent::getFieldsDefinitions($actionUrl, $report), + [ + 'callStack' => new Field\TextArea('Call stack', 'cs', [FC::Required => $this->requireAllFieldsByDefault], null, $this->isReadOnly), + 'rateLimit' => new Field\RateLimiter([ + new FixedWindow($actionUrl, 1, 30), // equivalent to once every 30 secs + new FixedWindow($actionUrl, 12, 3600), // equivalent to once every 5 minutes + new FixedWindow($actionUrl, 24, 86400), // equivalent to once every 30 minutes + ]), + ] + ); } } diff --git a/src/Form/Field.php b/src/Form/Field.php index f4b5eee..5f33ce0 100644 --- a/src/Form/Field.php +++ b/src/Form/Field.php @@ -2,64 +2,108 @@ namespace Veracrypt\CrashCollector\Form; +use Veracrypt\CrashCollector\Exception\ConstraintException; +use Veracrypt\CrashCollector\Exception\RateLimitExceedException; +use Veracrypt\CrashCollector\Form\Field\Constraint\ConstraintInterface; use Veracrypt\CrashCollector\Form\FieldConstraint as FC; +use Veracrypt\CrashCollector\Logger; +use Veracrypt\CrashCollector\RateLimiter\RateLimiterInterface; /** * @property-read ?string $value * @property-read ?string $errorMessage + * @property-read bool isValid + * + * @todo introduce a FieldInterface */ -class Field +abstract class Field { - protected $value; - protected $errorMessage; + protected ?string $errorMessage = null; - /** - * NB: constraints are checked in the order they are defined - */ - public function __construct( + protected function __construct( + public readonly string $inputType, public readonly string $label, public readonly string $inputName, - public readonly string $inputType, - public readonly array $constraints = [] + public readonly array $constraints = [], + protected mixed $value = null, + public readonly bool $isVisible = true, + public readonly bool $isReadonly = false ) { + $this->validateConstraintsDefinitions($constraints); } /** - * @param mixed $value - * @return bool false when the value is not valid * @throws \DomainException */ - public function setValue(mixed $value): bool + protected function validateConstraintsDefinitions(array $constraints): void { - $isValid = true; - - if ($value !== null) { - $value = trim($value); - - switch($this->inputType) { - case 'datetime': - if ($value !== '') { - if (strtotime($value) === false) { - $this->errorMessage = 'Value is not a valid datetime'; - $isValid = false; - } - } else { - // we allow either valid datetime strings, or null. No empty strings - $value = null; + foreach ($constraints as $constraint => $targetValue) { + switch ($constraint) { + case FC::Required: + break; + case FC::MaxLength: + case FC::MinLength: + if ($targetValue < 0) { + throw new \DomainException("Unsupported field max(/min) length: $targetValue"); + } + break; + case FC::RateLimit: + if (!($targetValue instanceof RateLimiterInterface)) { + throw new \DomainException("Unsupported configuration for rate-limit field: not a rate limiter object"); } break; + case FC::Custom: + if (!($targetValue instanceof ConstraintInterface)) { + throw new \DomainException("Unsupported configuration for field: not a custom constraint object"); + } + break; + default: + throw new \DomainException("Unsupported field constraint: '$constraint"); } } + } - $this->value = $value; + /** + * Used to set (and validate) the value submitted. + * NB: constraints are checked in the order they are defined. + * @param mixed $value null is used when the field is not present in the request received + * @return bool false when the value is not valid + */ + public function setValue(mixed $value): bool + { + $this->value = $this->validateValue($value); - if (!$isValid) { + if (null !== $this->errorMessage && '' !== $this->errorMessage) { return false; } + return $this->validateConstraints($value); + } + + /** + * Used to validate and optionally convert to the desired representation the value submitted. + * By default, it converts non-null values to strings and trims whitespace. + * For overriders: null is received when the field is not present in the request received and it should generally be + * let through unchanged. Vice-versa, it is not recommended to return null when a non-null value is received. + * NB: should set $this->errorMessage if a non-constraint is violated. + */ + protected function validateValue(mixed $value): null|string + { + return match ($value) { + null => null, + default => trim($value), + }; + } + + /** + * Used to validate the value submitted. + * NB: sets $this->errorMessage if a constraint is violated. + * @todo add support for more constraints: regex, ... + */ + protected function validateConstraints(?string $value): bool + { foreach ($this->constraints as $constraint => $targetValue) { - /// @todo add validation for minLength, regex, integer fields, datetimes, etc... switch ($constraint) { case FC::Required: if ($targetValue && ($value === '' || $value === null)) { @@ -68,29 +112,75 @@ public function setValue(mixed $value): bool } break; case FC::MaxLength: - /// @todo throw if $targetValue < 0 if ($targetValue > 0 && strlen($value) > $targetValue) { $this->errorMessage = "Value should not be longer than {$targetValue} characters"; return false; } break; - default: - throw new \DomainException("Unsupported field constraint: '$constraint"); + case FC::MinLength: + if ($targetValue > 0 && strlen($value) < $targetValue) { + $this->errorMessage = "Value should not be shorter than {$targetValue} characters"; + return false; + } + break; + case FC::RateLimit: + /// @todo can this be implemented as a Custom Constraint? + try { + $targetValue->validateRequest((string)$value); + } catch (RateLimitExceedException $e) { + $this->errorMessage = "You have submitted the form too many times. Please wait for a while before re-submitting"; + + /// @todo improve this - add some info on the specific form (and the client IP?) + $logger = Logger::getInstance('audit'); + $logger->info("Form was denied submission - rate limit achieved for field constraint: " . $e->getMessage()); + + return false; + } + break; + case FC::Custom: + /// @todo we should find a simple way to let the form users retrieve more info than just the exception message + try { + $targetValue->validateRequest((string)$value); + } catch (ConstraintException $e) { + $this->errorMessage = $e->getMessage(); + return false; + } + break; + // this is checked at constructor time + //default: + // throw new \DomainException("Unsupported field constraint: '$constraint"); } } return true; } - public function getData(): null|string|\DateTimeImmutable + public function setError(string $erroMessage) { - if ($this->value === null) { - return $this->value; - } - return match($this->inputType) { - 'datetime' => new \DateTimeImmutable($this->value), - default => $this->value, - }; + $this->errorMessage = $erroMessage; + } + + /** + * Returns the field value as usable by php code, which might differ from what is output + */ + public function getData(): mixed + { + return $this->value; + } + + public function isRequired(): bool + { + return array_key_exists(FC::Required, $this->constraints) && $this->constraints[FC::Required]; + } + + public function getMaxLength(): ?int + { + return array_key_exists(FC::MaxLength, $this->constraints) ? (int)$this->constraints[FC::MaxLength] : null; + } + + public function getMinLength(): ?int + { + return array_key_exists(FC::MinLength, $this->constraints) ? (int)$this->constraints[FC::MinLength] : null; } public function __get($name) @@ -99,6 +189,8 @@ public function __get($name) case 'value': case 'errorMessage': return $this->$name; + case 'isValid': + return null !== $this->errorMessage && '' !== $this->errorMessage; default: $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1); trigger_error('Undefined property via __get(): ' . $name . ' in ' . $trace[0]['file'] . ' on line ' . @@ -110,6 +202,7 @@ public function __isset($name) { return match ($name) { 'value', 'errorMessage' => isset($this->$name), + 'isValid' => true, default => false }; } diff --git a/src/Form/Field/AntiCSRF.php b/src/Form/Field/AntiCSRF.php new file mode 100644 index 0000000..5ec3334 --- /dev/null +++ b/src/Form/Field/AntiCSRF.php @@ -0,0 +1,43 @@ +validateToken((string)$value, $this->formActionUrl); + } catch (AntiCSRFException $e) { + $this->errorMessage = 'The ANTI-CSRF token has been tampered or is missing'; + $logger = Logger::getInstance('audit'); + $logger->info('ANTI-CSRF token tampering attempt. ' . $e->getMessage()); + } + return null; + } + + public function getAntiCSRFToken(): string + { + $antiCSRF = new AntiCSRFTokenManager(); + return $antiCSRF->getToken($this->formActionUrl); + } +} diff --git a/src/Form/Field/Constraint/ActiveUserEmailConstraint.php b/src/Form/Field/Constraint/ActiveUserEmailConstraint.php new file mode 100644 index 0000000..c0c643c --- /dev/null +++ b/src/Form/Field/Constraint/ActiveUserEmailConstraint.php @@ -0,0 +1,26 @@ +fetchUsersByEmail($value); + if (!$users || count($users) !== 1) { + throw new ConstraintUserNotFoundException('User matching email either not found or not unique'); + } + $user = $users[0]; + if (!$user->isActive()) { + throw new ConstraintUserInactiveException('User matching email is not active'); + } + $this->user = $user; + } +} diff --git a/src/Form/Field/Constraint/ActiveUserTokenConstraint.php b/src/Form/Field/Constraint/ActiveUserTokenConstraint.php new file mode 100644 index 0000000..6cb85a2 --- /dev/null +++ b/src/Form/Field/Constraint/ActiveUserTokenConstraint.php @@ -0,0 +1,69 @@ +repositoryClass(); + return $ph->verify($this->token->hash, $secret); + } + + /** + * @throws \RuntimeException or subclasses thereof + */ + public function validateRequest(?string $value = null): void + { + /** @var UserTokenRepository $repo */ + $repo = new $this->repositoryClass(); + + $tokenId = (int)$value; + if ($tokenId <= 0) { + throw new TokenNotFoundException('Token not found'); + } + $token = $repo->fetch($tokenId); + if ($token === null) { + throw new TokenNotFoundException('Token not found'); + } + $this->token = $token; + $user = $this->token->getUser(); + if ($user === null) { + throw new ConstraintUserNotFoundException('User matching token not found'); + } + if (!$user->isActive()) { + throw new ConstraintUserInactiveException('User matching token is not active'); + } + + $this->user = $user; + } + + public function getTokenRepository(): UserTokenRepository + { + return new $this->repositoryClass(); + } +} diff --git a/src/Form/Field/Constraint/ConstraintInterface.php b/src/Form/Field/Constraint/ConstraintInterface.php new file mode 100644 index 0000000..cfb2f3d --- /dev/null +++ b/src/Form/Field/Constraint/ConstraintInterface.php @@ -0,0 +1,13 @@ +repositoryClass(); + return $ph->verify($this->token->hash, $secret); + } + + /** + * @throws \RuntimeException or subclasses thereof + */ + public function validateRequest(?string $value = null): void + { + /** @var ReportTokenRepository $repo */ + $repo = new $this->repositoryClass(); + + $tokenId = (int)$value; + if ($tokenId <= 0) { + throw new TokenNotFoundException('Token not found'); + } + $token = $repo->fetch($tokenId); + if ($token === null) { + throw new TokenNotFoundException('Token not found'); + } + $this->token = $token; + $report = $this->token->getReport(); + if ($report === null) { + throw new ConstraintReportNotFoundException('Report matching token not found'); + } + + $this->report = $report; + } + + public function getTokenRepository(): ReportTokenRepository + { + return new $this->repositoryClass(); + } + + public function getReport(): null|CrashReport + { + return $this->report; + } +} diff --git a/src/Form/Field/Constraint/UserConstraintTrait.php b/src/Form/Field/Constraint/UserConstraintTrait.php new file mode 100644 index 0000000..c5e372e --- /dev/null +++ b/src/Form/Field/Constraint/UserConstraintTrait.php @@ -0,0 +1,15 @@ +user; + } +} diff --git a/src/Form/Field/DateTime.php b/src/Form/Field/DateTime.php new file mode 100644 index 0000000..5928e61 --- /dev/null +++ b/src/Form/Field/DateTime.php @@ -0,0 +1,38 @@ +errorMessage = 'Value is not a valid datetime'; + } + return $value; + } + + public function getData(): null|\DateTimeImmutable + { + return match($this->value) { + null => null, + default => new \DateTimeImmutable($this->value), + }; + } +} diff --git a/src/Form/Field/Email.php b/src/Form/Field/Email.php new file mode 100644 index 0000000..9ab810e --- /dev/null +++ b/src/Form/Field/Email.php @@ -0,0 +1,25 @@ +errorMessage = 'Value is not a valid email address'; + } + return $value; + } +} diff --git a/src/Form/Field/Hidden.php b/src/Form/Field/Hidden.php new file mode 100644 index 0000000..e3aa372 --- /dev/null +++ b/src/Form/Field/Hidden.php @@ -0,0 +1,13 @@ +limiter = new Limiter($constraints); + } + + /** + * @return null we can not enforce this via the function declaration, but this function only ever returns null + */ + protected function validateValue(mixed $value): null|string + { + try { + $this->limiter->validateRequest(); + } catch (RateLimitExceedException $e) { + $this->errorMessage = "You have submitted the form too many times. Please wait for a while before re-submitting"; + + /// @todo improve this - add some info on the specific form (and the client IP?) + $logger = Logger::getInstance('audit'); + $logger->info("Form was denied submission - rate limit achieved for field: " . $e->getMessage()); + } + return null; + } +} diff --git a/src/Form/Field/Redirect.php b/src/Form/Field/Redirect.php new file mode 100644 index 0000000..5d9bad6 --- /dev/null +++ b/src/Form/Field/Redirect.php @@ -0,0 +1,27 @@ +match($value)) { + $this->errorMessage = 'Tsk tsk tsk. Pen testing redirects?'; + + $logger = Logger::getInstance('audit'); + $logger->info("Hacking attempt? form submitted with invalid redirect url '$value'"); + } + // We reset the redirect to the previous (current) value - as otherwise the displayed form will keep showing + // a non-acceptable value. Also, pen-testing tools might believe that they achieve some injection of sorts... + return $this->value; + } +} diff --git a/src/Form/Field/SubmitButton.php b/src/Form/Field/SubmitButton.php new file mode 100644 index 0000000..9c18b0a --- /dev/null +++ b/src/Form/Field/SubmitButton.php @@ -0,0 +1,16 @@ +userConstraint = new ActiveUserTokenConstraint(ForgotPasswordTokenRepository::class); + $this->fields = [ + /// @todo add an is-integer constraint? + 'token' => new Field\Hidden('tkn', [ + FC::Required => true, + FC::RateLimit => new RateLimiter([ + new FixedWindow($actionUrl, 10, 300), // equivalent to once every 30 secs + new FixedWindow($actionUrl, 12, 3600), // equivalent to once every 5 minutes + new FixedWindow($actionUrl, 120, 86400), // equivalent to once every 12 minutes + ]), + FC::Custom => $this->userConstraint + ], $tokenId), + /// @todo get the field length from the TokenRepository + 'secret' => new Field\Hidden('sec', [ + FC::Required => true, + FC::MinLength => 128, + FC::MaxLength => 128, + ], $secret) + ]; + + parent::__construct($actionUrl); + } + + protected function validateSubmit(?array $request = null): void + { + // use the same error message used for invalid token-ids + if (!$this->userConstraint->validateHash($this->getFieldData('secret'))) { + $this->setError("Token not found"); + } + } + + public function isSubmitted(?array $request = null): bool + { + if ($request === null) { + $request = $this->getRequest(); + } + return array_key_exists('tkn', $request); + } + + public function getUser(): null|User + { + return $this->userConstraint->getUser(); + } +} diff --git a/src/Form/ForgotPasswordForm.php b/src/Form/ForgotPasswordForm.php new file mode 100644 index 0000000..a50f670 --- /dev/null +++ b/src/Form/ForgotPasswordForm.php @@ -0,0 +1,58 @@ +userConstraint = new ActiveUserEmailConstraint(); + $this->fields = [ + /// @todo get the field length from the Repo field + 'email' => new Field\Email('Email', 'em', [ + FC::Required => true, + FC::MaxLength => 254, + FC::RateLimit => new RateLimiter([ + new FixedWindow($actionUrl, 10, 300), // equivalent to once every 30 secs + new FixedWindow($actionUrl, 12, 3600), // equivalent to once every 5 minutes + new FixedWindow($actionUrl, 120, 86400), // equivalent to once every 12 minutes + ]), + FC::Custom => $this->userConstraint + ]) + ]; + + parent::__construct($actionUrl); + } + + public function handleRequest(?array $request = null): void + { + parent::handleRequest($request); + + // Allow hiding all error messages from the end user, except empty and too-long email, to avoid the enumeration + // of existing users email (but keep $this->isValid false) + if (!$this->isValid && str_starts_with($this->fields['email']->errorMessage, 'User matching email ')) { + $this->pretendIsValid = true; + } + } + + public function getUser(): null|User + { + return $this->userConstraint->getUser(); + } + + public function pretendIsValid(): bool + { + return $this->pretendIsValid; + } +} diff --git a/src/Form/Form.php b/src/Form/Form.php index c681f87..5a74a3d 100644 --- a/src/Form/Form.php +++ b/src/Form/Form.php @@ -2,6 +2,9 @@ namespace Veracrypt\CrashCollector\Form; +use Veracrypt\CrashCollector\Exception\FormFieldNotSubmittedException; +use Veracrypt\CrashCollector\Form\Field\SubmitButton; + /** * @property-read ?string $errorMessage */ @@ -11,17 +14,23 @@ abstract class Form const ON_POST = 2; const ON_BOTH = 3; - /** @var Field[] */ + /** @var Field[] $fields */ protected array $fields = []; protected string $submitLabel = 'Submit'; protected int $submitOn = self::ON_POST; protected bool $isValid = false; - protected string $errorMessage; + protected ?string $errorMessage = null; + protected string $submitInputName = 's'; + protected string|int $submitInputValue = 1; + + public function __construct(public readonly string $actionUrl) + { + } /** * @throws \DomainException */ - public function getField($fieldName): Field + public function getField(string $fieldName): Field { if (array_key_exists($fieldName, $this->fields)) { return $this->fields[$fieldName]; @@ -39,17 +48,15 @@ public function getMethod(): string public function getSubmit(): Field { - $f = new Field($this->submitLabel, 's', 'submit'); - $f->setValue(1); - return $f; + return new SubmitButton($this->submitLabel, $this->submitInputName, [], $this->submitInputValue); } public function isSubmitted(?array $request = null): bool { - $submit = $this->getSubmit(); if ($request === null) { $request = $this->getRequest(); } + $submit = $this->getSubmit(); return isset($request[$submit->inputName]) && $request[$submit->inputName] == $submit->value; } @@ -66,11 +73,46 @@ public function handleRequest(?array $request = null): void } foreach($this->fields as &$field) { if (!$field->setValue(array_key_exists($field->inputName, $request) ? $request[$field->inputName] : null)) { + // in case the field is not shown to the end user, we show its error message as the form's error message + if (!$field->isVisible) { + $this->setError($field->errorMessage); + } $this->isValid = false; } } + + if ($this->isValid()) { + $this->validateSubmit($request); + } + } + + /** + * To be overridden in forms which have custom validation rules besides single field validation. + * Called after field validation, only if all the fields did validate. + * Should set $this->isValid and $this->errorMessage if there's anything wrong. + * Should work preferably with values from $this->fields rather than $request, which is passed in as a commodity + */ + protected function validateSubmit(?array $request = null): void + { + } + + /** + * Make sure, before calling this, that the field was submitted. Typically, add a Required constraint to it. + * @throws \DomainException for invalid field names + * @throws FormFieldNotSubmittedException + */ + public function getFieldData(string $fieldName): mixed + { + $field = $this->getField($fieldName); + if ($field->value === null) { + throw new FormFieldNotSubmittedException("Form field '$fieldName' was not submitted"); + } + return $field->getData(); } + /** + * Returns values for all fields which were submitted. + */ public function getData(): array { $data = []; @@ -82,6 +124,20 @@ public function getData(): array return $data; } + /** + * @return string[] key: field name + */ + public function getFieldsErrors($onlyVisibleFields = true): array + { + $errors = []; + foreach($this->fields as $name => $field) { + if (($field->errorMessage !== '' && $field->errorMessage !== null) && ($field->isVisible || !$onlyVisibleFields)) { + $errors[$name] = $field->errorMessage; + } + } + return $errors; + } + public function getQueryStringParts(bool $includeSubmit = false) { if ($this->submitOn == self::ON_POST) { @@ -113,9 +169,16 @@ protected function getRequest() } } + public function setError(?string $errorMessage) + { + $this->errorMessage = $errorMessage; + $this->isValid = ($errorMessage === null || $errorMessage === ''); + } + public function __get($name) { switch ($name) { + //case 'actionUrl': case 'errorMessage': return $this->$name; default: @@ -128,6 +191,7 @@ public function __get($name) public function __isset($name) { return match ($name) { + //'actionUrl' => true, 'errorMessage' => isset($this->$name), default => false }; diff --git a/src/Form/LoginForm.php b/src/Form/LoginForm.php new file mode 100644 index 0000000..67f27a5 --- /dev/null +++ b/src/Form/LoginForm.php @@ -0,0 +1,42 @@ +rateLimiter = new RateLimiter([ + new FixedWindow($actionUrl, 5, 10), // equivalent to once every 2 secs + new FixedWindow($actionUrl, 80, 3600), // equivalent to once every 45 secs + new FixedWindow($actionUrl, 288, 86400), // equivalent to once every 5 minutes + ]); + + $this->fields = [ + /// @todo get the field length from the Repo fields + 'username' => new Field\Text('Username', 'un', [ + FC::Required => true, + FC::MaxLength => 180, + FC::RateLimit => $this->rateLimiter, + ]), + 'password' => new Field\Password('Password', 'pw', [FC::Required => true, FC::MaxLength => PasswordHasher::MAX_PASSWORD_LENGTH]), + 'redirect' => new Field\Redirect('r', [FC::Required => true], $redirect), + ]; + + parent::__construct($actionUrl); + } + + public function onSuccessfulLogin() + { + $this->rateLimiter->reset($this->getFieldData('username')); + } +} diff --git a/src/Form/PasswordUpdateBaseForm.php b/src/Form/PasswordUpdateBaseForm.php new file mode 100644 index 0000000..c9433b1 --- /dev/null +++ b/src/Form/PasswordUpdateBaseForm.php @@ -0,0 +1,34 @@ +fields = $this->getFieldsDefinitions($actionUrl); + parent::__construct($actionUrl); + } + + protected function getFieldsDefinitions(string $actionUrl): array + { + return [ + 'newPassword' => new Field\Password('New Password', 'np', [FC::Required => true, FC::MaxLength => PasswordHasher::MAX_PASSWORD_LENGTH]), + 'newPasswordConfirm' => new Field\Password('Confirm new Password', 'npc', [FC::Required => true, FC::MaxLength => PasswordHasher::MAX_PASSWORD_LENGTH]), + ]; + } + + protected function validateSubmit(?array $request = null): void + { +/// @check: do we need the ref assignment? + /** @var Field $npcField */ + $npcField =& $this->fields['newPasswordConfirm']; + if ($this->fields['newPassword']->getData() !== $npcField->getData()) { + $npcField->setError('The password does not match'); + $this->isValid = false; + } + } +} diff --git a/src/Form/ResetPasswordForm.php b/src/Form/ResetPasswordForm.php new file mode 100644 index 0000000..519e909 --- /dev/null +++ b/src/Form/ResetPasswordForm.php @@ -0,0 +1,46 @@ +currentUser = $currentUser; + parent::__construct($actionUrl); + } + + protected function getFieldsDefinitions(string $actionUrl): array + { + return array_merge( + ['oldPassword' => new Field\Password('Current Password', 'cp', [FC::Required => true, FC::MaxLength => PasswordHasher::MAX_PASSWORD_LENGTH])], + parent::getFieldsDefinitions($actionUrl), + ['antiCSRF' => new Field\AntiCSRF('ac', $actionUrl)] + ); + } + + protected function validateSubmit(?array $request = null): void + { + parent::validateSubmit($request); + if ($this->isValid) { + $authenticator = new UsernamePasswordAuthenticator(); + try { + $authenticator->authenticate($this->currentUser->getUserIdentifier(), $this->fields['oldPassword']->getData()); + } catch (BadCredentialsException) { + $this->fields['oldPassword']->setError('The current password is wrong'); + $this->isValid = false; + } + /// @todo what to do in case we get an AccountExpiredException or UserNotFoundException? + /// This can happen, hopefully infrequently, when the form is displayed to a user still active, and + /// then submitted after the user got deactivated/deleted + } + } +} diff --git a/src/Form/SetNewPasswordForm.php b/src/Form/SetNewPasswordForm.php new file mode 100644 index 0000000..90e4f04 --- /dev/null +++ b/src/Form/SetNewPasswordForm.php @@ -0,0 +1,72 @@ +tokenId = $tokenId; + $this->secret = $secret; + parent::__construct($actionUrl); + } + + protected function getFieldsDefinitions(string $actionUrl, string $token = ''): array + { + /// @todo should we move to starting a session before displaying this form, and add an anti-csrf token instead of rate-limiting? + $this->userConstraint = new ActiveUserTokenConstraint(ForgotPasswordTokenRepository::class); + return array_merge(parent::getFieldsDefinitions($actionUrl), [ + 'token' => new Field\Hidden('tkn', [ + FC::Required => true, + FC::RateLimit => new RateLimiter([ + new FixedWindow($actionUrl, 10, 300), // equivalent to once every 30 secs + new FixedWindow($actionUrl, 12, 3600), // equivalent to once every 5 minutes + new FixedWindow($actionUrl, 120, 86400), // equivalent to once every 12 minutes + ]), + FC::Custom => $this->userConstraint + ], $this->tokenId), + /// @todo get the field length from the TokenRepository + 'secret' => new Field\Hidden('sec', [ + FC::Required => true, + FC::MinLength => 128, + FC::MaxLength => 128, + ], $this->secret) + ]); + } + + protected function validateSubmit(?array $request = null): void + { + parent::validateSubmit($request); + if ($this->isValid) { + if (!$this->userConstraint->validateHash($this->getFieldData('secret'))) { + // use the same error message used for invalid token-ids + $this->setError("Token not found"); + } + } + } + + public function getUser(): null|User + { + return $this->userConstraint->getUser(); + } + + public function getTokenRepository(): UserTokenRepository + { + return $this->userConstraint->getTokenRepository(); + } +} diff --git a/src/Logger.php b/src/Logger.php new file mode 100644 index 0000000..67e5b20 --- /dev/null +++ b/src/Logger.php @@ -0,0 +1,78 @@ + 100, + LogLevel::INFO => 200, + LogLevel::NOTICE => 250, + LogLevel::WARNING => 300, + LogLevel::ERROR => 400, + LogLevel::CRITICAL => 500, + LogLevel::ALERT => 550, + LogLevel::EMERGENCY => 600, + ]; + + /** + * Loggers do not really need to be singletons, but doing things this way helps with keeping the configuration + * for each logger tucked up neatly in a single place + * @throws \DomainException + */ + public static function getInstance(string $name): Logger + { + if (!array_key_exists($name, self::$_loggers)) { + switch ($name) { + case 'audit': + self::$_loggers[$name] = new self($_ENV['AUDIT_LOG_FILE'], $_ENV['AUDIT_LOG_LEVEL']); + break; + default: + throw new \DomainException("Logger '$name' in not configured"); + } + } + + return self::$_loggers[$name]; + } + + public function __construct(string|\Stringable $logFile, $logLevel) + { + if (!str_contains('/', $logFile)) { + $logDir = $_ENV['LOG_DIR']; + if ($logDir != '') { + $logDir = rtrim($logDir, '/') . '/'; + } + $logFile = $logDir . $logFile; + } + $this->logFile = $logFile; + $this->logLevel = $logLevel; + } + + /** + * Logs with an arbitrary level. + * + * @param string $level + */ + public function log($level, string|\Stringable $message, array $context = []): void + { + /// @todo throw a \DomainException if $level is unsupported + + if ($this->levelWeights[$this->logLevel] > $this->levelWeights[$level]) { + return; + } + + file_put_contents($this->logFile, + date('c') . ' ' . strtoupper($level) . ' ' . $message . ' ' . str_replace("\n", ' ', json_encode($context)) . "\n", + FILE_APPEND + ); + } +} diff --git a/src/Mailer/Email.php b/src/Mailer/Email.php new file mode 100644 index 0000000..0f36df6 --- /dev/null +++ b/src/Mailer/Email.php @@ -0,0 +1,55 @@ +from = $from; + return $this; + } + + public function getFrom(): string + { + return $this->from; + } + + public function to(string $to): Email + { + $this->to = $to; + return $this; + } + + public function getTo(): string + { + return $this->to; + } + + public function subject(string $subject): Email + { + $this->subject = $subject; + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function text(string $text): Email + { + $this->text = $text; + return $this; + } + + public function getText(): string + { + return $this->text; + } +} diff --git a/src/Mailer/Mailer.php b/src/Mailer/Mailer.php new file mode 100644 index 0000000..d3fb3c0 --- /dev/null +++ b/src/Mailer/Mailer.php @@ -0,0 +1,25 @@ + $message->getFrom(), + ]; + // replace single \n chars with \r\n + $text = preg_replace('/(^|[^\\r])\\n/', "\\1\r\n", $message->getText()); + if (!mail($message->getTo(), $message->getSubject(), $text, $additionalHeaders)) { + throw new \RuntimeException("Mail delivery failed"); + } + } +} diff --git a/src/PHPErrorHandler.php b/src/PHPErrorHandler.php index aa4d60f..e46337a 100644 --- a/src/PHPErrorHandler.php +++ b/src/PHPErrorHandler.php @@ -7,13 +7,13 @@ */ class PHPErrorHandler { - protected $errorTypesToHandle = array( + protected array $errorTypesToHandle = [ E_ERROR => 'E_ERROR', E_PARSE => 'E_PARSE', E_USER_ERROR => 'E_USER_ERROR', E_COMPILE_ERROR => 'E_COMPILE_ERROR', E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', - ); + ]; public function handle(): void { @@ -64,7 +64,7 @@ protected function shouldHandleError(array $error): bool /** * NB: this only works insofar at least the dotenv-based loading of env vars has succeeded - * @todo decide how to handle - log, email, other? + * @todo decide how to handle - log, email, Slack, other? */ protected function notifyOfError(array $error): void { diff --git a/src/RateLimiter/Constraint/FixedWindow.php b/src/RateLimiter/Constraint/FixedWindow.php new file mode 100644 index 0000000..ee20e63 --- /dev/null +++ b/src/RateLimiter/Constraint/FixedWindow.php @@ -0,0 +1,73 @@ +connect(); + + $key = $this->getKey($extraIdentifier); + if (!self::$rh->exists($key)) { + if (!self::$rh->set($key, 1) || !self::$rh->expire($key, $this->intervalLength)) { + throw new \RuntimeException('Error saving RateLimiter data in Redis'); + } + } else { + $totalCalls = self::$rh->incr($key); + if ($totalCalls === false) { + throw new \RuntimeException('Error getting RateLimiter data from Redis'); + } + if ($totalCalls > $this->maxHitsPerInterval) { + throw new RateLimitExceedException("More than {$this->maxHitsPerInterval} requests were made in {$this->intervalLength} seconds"); + } + } + } + + /** + * @throws \RedisException + */ + public function reset(?string $extraIdentifier = null): void + { + self::$rh->unlink($this->getKey()); + } + + /// @todo allow using other means than the client IP to group together requests, esp. when $extraIdentifier is not null + protected function getKey(?string $extraIdentifier = null): string + { + // the order of fields is chosen to allow wildcard purging of all constraints matching a given identifier, + // regardless of constraint type / config + return $this->prefix . '|' . str_replace('|', '||', $this->identifier) . '|' . + ($extraIdentifier !== null ? md5($extraIdentifier) : '') . '|' . + $this->getClientIP() . '|' . + $this->intervalLength . '|' . $this->maxHitsPerInterval . '|' . $this->postfix; + } +} diff --git a/src/RateLimiter/Constraint/RedisConstraint.php b/src/RateLimiter/Constraint/RedisConstraint.php new file mode 100644 index 0000000..cb91f5a --- /dev/null +++ b/src/RateLimiter/Constraint/RedisConstraint.php @@ -0,0 +1,12 @@ +constraints as $constraint) { + $constraint->validateRequest($extraIdentifier); + } + } + + public function reset(?string $extraIdentifier = null): void + { + foreach ($this->constraints as $constraint) { + $constraint->reset($extraIdentifier); + } + } +} diff --git a/src/RateLimiter/RateLimiterInterface.php b/src/RateLimiter/RateLimiterInterface.php new file mode 100644 index 0000000..1da3e17 --- /dev/null +++ b/src/RateLimiter/RateLimiterInterface.php @@ -0,0 +1,18 @@ +fields = [ - 'id' => new Field(null, 'integer', [FC::NotNull => true, FC::PK => true, FC::Autoincrement => true]), + 'id' => new Field('id', 'integer', [FC::NotNull => true, FC::PK => true, FC::Autoincrement => true]), 'date_reported' => new Field('dateReported', 'integer', [FC::NotNull => true]), 'program_version' => new Field('programVersion', 'varchar', [FC::Length => 255, FC::NotNull => true]), 'os_version' => new Field('osVersion', 'varchar', [FC::Length => 255, FC::NotNull => true]), @@ -24,25 +29,68 @@ public function __construct() 'error_address' => new Field('errorAddress', 'varchar', [FC::Length => 255, FC::NotNull => true]), 'call_stack' => new Field('callStack', 'blob', [FC::NotNull => true]), ]; - + /// @todo should we just add a covering index which uses all columns? If so, figure out first the cardinality of each + $this->indexes = [ + 'idx_' . $this->tableName . '_dr' => new Index(['date_reported']), + 'idx_' . $this->tableName . '_pm' => new Index(['program_version']), + 'idx_' . $this->tableName . '_ov' => new Index(['os_version']), + 'idx_' . $this->tableName . '_ha' => new Index(['hw_architecture']), + 'idx_' . $this->tableName . '_es' => new Index(['executable_checksum']), + 'idx_' . $this->tableName . '_ec' => new Index(['error_category']), + 'idx_' . $this->tableName . '_ea' => new Index(['error_address']), + 'idx_' . $this->tableName . '_cs' => new Index(['call_stack']), + ]; parent::__construct(); } /** * Note: this does not validate the length of the fields, nor truncate them. The length validation is left to the Form + * @throws \PDOException */ public function createReport(string $programVersion, string $osVersion, string $hwArchitecture, string $executableChecksum, string $errorCategory, string $errorAddress, string $callStack): CrashReport { $dateReported = time(); - $cr = new CrashReport($dateReported, $programVersion, $osVersion, $hwArchitecture, $executableChecksum, $errorCategory, + $cr = new CrashReport(null, $dateReported, $programVersion, $osVersion, $hwArchitecture, $executableChecksum, $errorCategory, $errorAddress, $callStack); - $this->storeEntity($cr); - return $cr; + $autoincrements = $this->storeEntity($cr); + // we have to create a new entity object in order to inject the id into it + return new CrashReport($autoincrements['id'], $dateReported, $programVersion, $osVersion, $hwArchitecture, + $executableChecksum, $errorCategory, $errorAddress, $callStack); + } + + /** + * @throws \PDOException + */ + public function fetchReport(int $id): CrashReport|null + { + $query = $this->buildFetchEntityQuery() . ' where id = :id'; + $stmt = self::$dbh->prepare($query); + $stmt->bindValue(':id', $id); + $stmt->execute(); + $result = $stmt->fetch(\PDO::FETCH_ASSOC); + return $result ? new CrashReport(...$result) : null; + } + + /** + * @throws \PDOException + */ + public function deleteReport(int $id): bool + { + $query = 'delete from ' . $this->tableName . ' where id = :id'; + $stmt = self::$dbh->prepare($query); + $stmt->bindValue(':id', $id); + $stmt->execute(); + $deleted = (bool)$stmt->rowCount(); + //if ($deleted) { + // $this->logger->debug("Report '$id' was deleted"); + //} + return $deleted; } /** * @return CrashReport[] + * @throws \PDOException */ public function searchReports(int $limit, int $offset = 0, ?string $programVersion = null, ?string $osVersion = null, ?string $hwArchitecture = null, ?string $executableChecksum = null, ?string $errorCategory = null, ?string $errorAddress = null, @@ -69,6 +117,9 @@ public function searchReports(int $limit, int $offset = 0, ?string $programVersi return array_map(static fn($result) => new CrashReport(...$result), $results); } + /** + * @throws \PDOException + */ public function countReports(?string $programVersion = null, ?string $osVersion = null, ?string $hwArchitecture = null, ?string $executableChecksum = null, ?string $errorCategory = null, ?string $errorAddress = null, null|int|DateTimeInterface $minDate = null, null|int|DateTimeInterface $maxDate = null): int diff --git a/src/Repository/DatabaseRepository.php b/src/Repository/DatabaseRepository.php new file mode 100644 index 0000000..9ef0d32 --- /dev/null +++ b/src/Repository/DatabaseRepository.php @@ -0,0 +1,103 @@ +connect(); + $this->createTableIfNeeded(); + } + + /** + * @throws \DomainException + */ + /*public function getField(string $entityFieldName): Field + { + foreach($this->fields as $field) { + if ($field->entityField === $entityFieldName) { + return $field; + } + } + throw new \DomainException("Repository has no field named '$entityFieldName'"); + }*/ + + protected function buildFetchEntityQuery(): string + { + $query = 'select '; + foreach($this->fields as $colName => $field) { + if ($field->entityField == '') { + continue; + } + $query .= $colName; + if ($field->entityField !== $colName) { + $query .= ' as ' . $field->entityField; + } + $query .= ', '; + } + return substr($query, 0, -2) . ' from ' . $this->tableName; + } + + /** + * @return null|array when there are autoincrement cols, and no value is passed in for those, their value is returned + * @throws \PDOException + */ + protected function storeEntity($value): null|array + { + $query = 'insert into ' . $this->tableName . ' ('; + $bindCols = []; + $autoIncrementCols = []; + foreach($this->fields as $colName => $field) { + if (isset($field->constraints[FieldConstraint::Autoincrement]) && $field->constraints[FieldConstraint::Autoincrement]) { + $entityField = $field->entityField; + if ($entityField == '' || $value->$entityField === null) { + $autoIncrementCols[] = $colName; + continue; + } + } + if ($field->entityField == '') { + continue; + } + $bindCols[] = $colName; + } + $query .= implode(', ', $bindCols) . ') values (:' . implode(', :', $bindCols) . ')'; + if ($autoIncrementCols) { + // 'returning' is supported by sqlite >= ..., mariadb >= 10.5, postgresql + $query .= ' returning ' . implode(', ', $autoIncrementCols); + } + + $stmt = self::$dbh->prepare($query); + /// @todo test: can `bindvalue` or `execute` fail without throwing? + foreach($this->fields as $colName => $field) { + if (!in_array($colName, $bindCols)) { + continue; + } + $entityField = $field->entityField; + $val = $value->$entityField; + if ($field->type === 'bool') { + // we cast to int as otherwise SQLite will store php false as ''... + $val = (int)$val; + } + $stmt->bindValue(":$colName", $val); + } + $stmt->execute(); + + if ($autoIncrementCols) { + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + return null; + } +} diff --git a/src/Repository/Field.php b/src/Repository/Field.php index 111d252..f3fb726 100644 --- a/src/Repository/Field.php +++ b/src/Repository/Field.php @@ -2,12 +2,22 @@ namespace Veracrypt\CrashCollector\Repository; +use Veracrypt\CrashCollector\Storage\Database\Column; + class Field { + use Column; + + /** + * @param mixed $constraints keys must be FieldConstraint constants + */ public function __construct( public readonly ?string $entityField, - public readonly string $type, - public readonly array $constraints - ) { + string $type, + array $constraints + ) + { + $this->type = $type; + $this->constraints = $constraints; } } diff --git a/src/Repository/ForgotPasswordTokenRepository.php b/src/Repository/ForgotPasswordTokenRepository.php new file mode 100644 index 0000000..081bc1b --- /dev/null +++ b/src/Repository/ForgotPasswordTokenRepository.php @@ -0,0 +1,27 @@ + new Field('reportId', 'integer', [FC::NotNull => true]), + ]); + } + + protected function getForeignKeyDefinitions(): array + { + return [ + /// @todo make 'crash_report' a static var or class const of CrashReportRepository, so that we can grab it from there + new ForeignKey(['report_id'], 'crash_report', ['id'], ForeignKeyAction::Cascade, ForeignKeyAction::Cascade), + ]; + } + + public function createToken(string $reportId, string $hash): ReportToken + { + $args['id'] = null; + $args['hash'] = $hash; + $args['reportId'] = $reportId; + $args['dateCreated'] = time(); + $args['expirationDate'] = $this->newTokenExpirationDate(); + $token = new $this->entityClass(...$args); + $autoincrements = $this->storeEntity($token); + // we have to create a new entity object in order to inject the id into it + $args['id'] = $autoincrements['id']; + return new $this->entityClass(...$args); + } +} diff --git a/src/Repository/Repository.php b/src/Repository/Repository.php deleted file mode 100644 index 56bb3e8..0000000 --- a/src/Repository/Repository.php +++ /dev/null @@ -1,142 +0,0 @@ -connect(); - $this->createTableIfNeeded(); - } - - /** - * @throws \DomainException - */ - public function getField(string $entityFieldName): Field - { - foreach($this->fields as $field) { - if ($field->entityField === $entityFieldName) { - return $field; - } - } - throw new \DomainException("Repository has no field named '$entityFieldName'"); - } - - /** - * A few notes on the SQLite type system (full docs at https://www.sqlite.org/datatype3.html), for the unwary: - * - any column can hold any type! - * - type juggling is in effect, but type-conversion rules are not the same as in php. Notably, expressions have no type! - * - there is no 'bool' or 'date' column/data type. Columns defined as such get a 'numeric' preferential type (aka 'affinity') - * - the length limit on varchar columns is ignored - * @throws \DomainException, \PDOException - */ - protected function createTable(): void - { - $query = 'CREATE TABLE ' . $this->tableName . ' ('; - foreach ($this->fields as $colName => $f) { - $query .= $colName . ' ' . $f->type . ' '; - $constraints = $f->constraints; - if (isset($constraints[FC::Length])) { - $query .= '(' . $f->constraints[FC::Length] . ') '; - unset($constraints[FC::Length]); - } - foreach($constraints as $cn => $cv) { - switch($cn) { - case FC::PK: - if ($cv) { - $query .= 'primary key '; - } - break; - case FC::Autoincrement: - if ($cv) { - $query .= 'autoincrement '; - } - break; - case FC::NotNull: - if ($cv) { - $query .= 'not null '; - } - break; - case FC::Unique: - if ($cv) { - $query .= 'unique '; - } - break; - case FC::Default: - if ($cv !== null) { - $query .= 'default ' . $cv . ' '; - } - break; - default: - throw new \DomainException("Unsupported Field constraint '$cn'"); - } - } - $query = substr($query, 0, -1) . ', '; - } - $query = substr($query, 0, -2) . ')'; - - /// @todo convert PDO exceptions into repository exceptions? - self::$dbh->exec($query); - } - - protected function buildFetchEntityQuery(): string - { - $query = 'select '; - foreach($this->fields as $col => $field) { - if ($field->entityField == '') { - continue; - } - $query .= $col; - if ($field->entityField !== $col) { - $query .= ' as ' . $field->entityField; - } - $query .= ', '; - } - return substr($query, 0, -2) . ' from ' . $this->tableName; - } - - /** - * @throws \PDOException - */ - protected function storeEntity($value): void - { - $query = 'insert into ' . $this->tableName . ' ('; - $vq = ''; - foreach($this->fields as $col => $field) { - if ($field->entityField == '') { - continue; - } - $query .= $col . ', '; - $vq .= ":$col" . ', '; - } - $query = substr($query, 0, -2) . ') values (' . substr($vq, 0, -2) . ')'; - - $stmt = self::$dbh->prepare($query); - /// @todo test: can `bindvalue` or `execute` fail without throwing? - foreach($this->fields as $col => $field) { - if ($field->entityField == '') { - continue; - } - $entityField = $field->entityField; - $val = $value->$entityField; - if ($field->type === 'bool') { - // we cast to int as otherwise SQLite will store php false as ''... - $val = (int)$val; - } - $stmt->bindValue(":$col", $val); - } - $stmt->execute(); - } -} diff --git a/src/Repository/Storage/Database.php b/src/Repository/Storage/Database.php deleted file mode 100644 index 060a5a0..0000000 --- a/src/Repository/Storage/Database.php +++ /dev/null @@ -1,61 +0,0 @@ -setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); - } - } - - /** - * Check if a table exists in the database - * - * @param string $tableName - * @return bool - * @throws ... - */ - protected function tableExists(string $tableName): bool - { - if (!is_array(self::$tableDefs)) { - self::$tableDefs = $this->listTables(); - } - return in_array($tableName, self::$tableDefs); - } - - /** - * List all db tables accessible to the current user. - * - * @return string[] value has to be the table name - * @throws \DomainException - * @throws \PDOException - */ - protected function listTables(): array - { - $dbType = self::$dbh->getAttribute(PDO::ATTR_DRIVER_NAME); - switch ($dbType) { - case 'sqlite': - $query = "SELECT name FROM sqlite_schema WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'"; - break; - default: - throw new \DomainException("Database type '$dbType' is not supported"); - } - - return self::$dbh->query($query)->fetchAll(PDO::FETCH_COLUMN, 0); - } -} diff --git a/src/Repository/Storage/DatabaseTable.php b/src/Repository/Storage/DatabaseTable.php deleted file mode 100644 index 7ff3214..0000000 --- a/src/Repository/Storage/DatabaseTable.php +++ /dev/null @@ -1,53 +0,0 @@ -tableExists) { - return false; - } - - if ($this->tableExists($this->tableName)) { - $this->tableExists = true; - return false; - } - - $this->createTable(); - - $this->tableExists = true; - return true; - } - - /** - * @return void - * @throws \DomainException in case of bad config (unsupported database) - * @throws \PDOException in case of failure creating the db table - */ - abstract protected function createTable(): void; -} diff --git a/src/Repository/TokenRepository.php b/src/Repository/TokenRepository.php new file mode 100644 index 0000000..61c1803 --- /dev/null +++ b/src/Repository/TokenRepository.php @@ -0,0 +1,83 @@ +fields = $this->getFieldsDefinitions(); + $this->foreignKeys = $this->getForeignKeyDefinitions(); + $this->indexes = $this->getIndexesDefinitions(); + + parent::__construct(); + } + + protected function getFieldsDefinitions() + { + return [ + 'id' => new Field('id', 'integer', [FC::NotNull => true, FC::PK => true, FC::Autoincrement => true]), + 'hash' => new Field('hash', 'varchar', [FC::Length => 255, FC::NotNull => true]), + 'date_created' => new Field('dateCreated', 'integer', [FC::NotNull => true]), + 'expiration_date' => new Field('expirationDate', 'integer', []), + // could add cols: usage_count, last_usage_datetime + ]; + } + + protected function getForeignKeyDefinitions(): array + { + return []; + } + + protected function getIndexesDefinitions(): array + { + return [ + // used by the purge command + 'idx_' . $this->tableName . '_ed' => new Index(['expiration_date']), + ]; + } + + abstract protected function newTokenExpirationDate(): null|int; + + public function fetch(int $id): null|Token + { + $query = $this->buildFetchEntityQuery() . ' where id = :id'; + $stmt = self::$dbh->prepare($query); + $stmt->bindValue(':id', $id); + $stmt->execute(); + $result = $stmt->fetch(\PDO::FETCH_ASSOC); + return $result ? new $this->entityClass(...$result) : null; + } + + public function delete(int $id): bool + { + $query = 'delete from ' . $this->tableName . ' where id = :id'; + $stmt = self::$dbh->prepare($query); + $stmt->bindValue(':id', $id); + $stmt->execute(); + return (bool)$stmt->rowCount(); + } + + /** + * Removes all expired tokens + */ + public function prune(): bool + { + $query = 'delete from ' . $this->tableName . ' where expiration_date <= :now'; + $stmt = self::$dbh->prepare($query); + $stmt->bindValue(':now', time()); + $stmt->execute(); + return (bool)$stmt->rowCount(); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index c99f813..ef0b310 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -3,21 +3,28 @@ namespace Veracrypt\CrashCollector\Repository; use Veracrypt\CrashCollector\Entity\User; +use Veracrypt\CrashCollector\Logger; use Veracrypt\CrashCollector\Repository\FieldConstraint as FC; +use Veracrypt\CrashCollector\Storage\Database\Index; -class UserRepository extends Repository +class UserRepository extends DatabaseRepository { - protected $tableName = 'auth_user'; + protected string $tableName = 'auth_user'; + protected Logger $logger; + /** + * @throws \DomainException in case of unsupported database type + * @trows \PDOException + */ public function __construct() { // Col names, type and size are inspired from Django + Symfony. $this->fields = [ - /// @todo should we disallow '' as value for all non-null string fields? Sqlite f.e. supports `CHECK()` /// @todo make the SQL more portable - `autoincrement` does not exist in either MySQL or Postgresql - drop the id col altogether? 'id' => new Field(null, 'integer', [FC::NotNull => true, FC::PK => true, FC::Autoincrement => true]), 'username' => new Field('username', 'varchar', [FC::Length => 180, FC::NotNull => true, FC::Unique => true]), 'password' => new Field('passwordHash', 'varchar', [FC::Length => 255, FC::NotNull => true]), + // q: should we make emails unique? 'email' => new Field('email', 'varchar', [FC::Length => 254, FC::NotNull => true]), 'first_name' => new Field('firstName', 'varchar', [FC::Length => 150, FC::NotNull => true]), 'last_name' => new Field('lastName', 'varchar', [FC::Length => 150, FC::NotNull => true]), @@ -27,12 +34,19 @@ public function __construct() //'is_staff' => new Field('isStaff', 'bool', [FC::NotNull => true, FC::Default => 'false']), 'is_superuser' => new Field('isSuperuser', 'bool', [FC::NotNull => true, FC::Default => 'false']), ]; + $this->indexes = [ + // afaik, an unique index on username will have been created automatically to enforce FC::Unique + //'idx_' . $this->tableName . '_un' => new Index(['username'], true), + 'idx_' . $this->tableName . '_em' => new Index(['email']), + ]; + $this->logger = Logger::getInstance('audit'); parent::__construct(); } /** * Note: this does not validate the length of the fields, nor truncate or validate them + * @throws \PDOException */ public function createUser(string $username, string $passwordHash, string $email, string $firstName, string $lastName, bool $isSuperUser = false, bool $isActive = true): User @@ -40,26 +54,43 @@ public function createUser(string $username, string $passwordHash, string $email $dateJoined = time(); $user = new User($username, $passwordHash, $email, $firstName, $lastName, $dateJoined, null, $isActive, $isSuperUser); $this->storeEntity($user); + $this->logger->debug("User '$username' was created"); return $user; } - /* + /** + * @throws \PDOException + */ public function fetchUser(string $username): User|null { $query = $this->buildFetchEntityQuery() . ' where username = :username'; $stmt = self::$dbh->prepare($query); $stmt->bindValue(':username', $username); $stmt->execute(); - $result = $stmt->fetchObject(User::class); - return $result ? $result : null; + $result = $stmt->fetch(\PDO::FETCH_ASSOC); + return $result ? new User(...$result) : null; + } + + /** + * @return User[] + * @throws \PDOException + */ + public function fetchUsersByEmail(string $email): array + { + $query = $this->buildFetchEntityQuery() . ' where email = :email'; + $stmt = self::$dbh->prepare($query); + $stmt->bindValue(':email', $email); + $stmt->execute(); + $results = $stmt->fetchAll(\PDO::FETCH_NUM); + return array_map(static fn($result) => new User(...$result), $results); } - */ /** * NB: passing in an empty string for any value will trigger the data to be updated in the DB, unlike passing in a NULL. * This might be unexpected... - * @todo we could allow the username to be changed too, by adding a $newUsername argument + * @todo we could allow the username to be changed too, by adding a $newUsername argument (unless we make it the PK...) * @throws \BadMethodCallException + * @throws \PDOException */ public function updateUser(string $username, ?string $passwordHash = null, ?string $email = null, ?string $firstName = null, ?string $lastName = null, ?bool $isSuperUser = null, ?bool $isActive = null): bool @@ -98,36 +129,67 @@ public function updateUser(string $username, ?string $passwordHash = null, ?stri } $stmt->bindValue(":username", $username); $stmt->execute(); - return (bool)$stmt->rowCount(); + $updated = (bool)$stmt->rowCount(); + if ($updated) { + $this->logger->debug("User '$username' was updated"); + } + return $updated; } + /** + * @throws \PDOException + */ public function deleteUser(string $username): bool { $query = 'delete from ' . $this->tableName . ' where username = :username'; $stmt = self::$dbh->prepare($query); $stmt->bindValue(':username', $username); $stmt->execute(); - return (bool)$stmt->rowCount(); + $deleted = (bool)$stmt->rowCount(); + if ($deleted) { + $this->logger->debug("User '$username' was deleted"); + } + return $deleted; } + /** + * NB: this returns false if the user exists and if it was already activated + * @throws \PDOException + */ public function activateUser(string $username): bool { - $query = 'update ' . $this->tableName . ' set is_active = true where username = :username'; + $query = 'update ' . $this->tableName . ' set is_active = true where username = :username and is_active = false'; $stmt = self::$dbh->prepare($query); $stmt->bindValue(':username', $username); $stmt->execute(); - return (bool)$stmt->rowCount(); + $activated = (bool)$stmt->rowCount(); + if ($activated) { + $this->logger->debug("User '$username' was activated"); + } + return $activated; } + /** + * NB: this returns false if the user exists and it was already deactivated + * @throws \PDOException + */ public function deactivateUser(string $username): bool { - $query = 'update ' . $this->tableName . ' set is_active = false where username = :username'; + $query = 'update ' . $this->tableName . ' set is_active = false where username = :username and is_active = true'; $stmt = self::$dbh->prepare($query); $stmt->bindValue(':username', $username); $stmt->execute(); - return (bool)$stmt->rowCount(); + $deactivated = (bool)$stmt->rowCount(); + if ($deactivated) { + $this->logger->debug("User '$username' was deactivated"); + } + return $deactivated; } + /** + * Call this when the user logged in + * @throws \PDOException + */ public function userLoggedIn(string $username): bool { /// @todo add a condition on existing last_login not being later than the new one? @@ -142,6 +204,7 @@ public function userLoggedIn(string $username): bool /** * @return mixed[][] we return arrays instead of value-objects, to make it easy for the console table helper. * This is also why the password hash is omitted. + * @throws \PDOException */ public function listUsers(): Array { diff --git a/src/Repository/UserTokenRepository.php b/src/Repository/UserTokenRepository.php new file mode 100644 index 0000000..8432121 --- /dev/null +++ b/src/Repository/UserTokenRepository.php @@ -0,0 +1,43 @@ + new Field('username', 'varchar', [FC::Length => 180, FC::NotNull => true]), + ]); + } + + protected function getForeignKeyDefinitions(): array + { + return [ + /// @todo make 'auth_user' a static var or class const of UserRepository, so that we can grab it from there + new ForeignKey(['username'], 'auth_user', ['username'], ForeignKeyAction::Cascade, ForeignKeyAction::Cascade), + ]; + } + + public function createToken(string $userName, string $hash): UserToken + { + $args['id'] = null; + $args['hash'] = $hash; + $args['username'] = $userName; + $args['dateCreated'] = time(); + $args['expirationDate'] = $this->newTokenExpirationDate(); + $token = new $this->entityClass(...$args); + $autoincrements = $this->storeEntity($token); + // we have to create a new entity object in order to inject the id into it + $args['id'] = $autoincrements['id']; + return new $this->entityClass(...$args); + } +} diff --git a/src/Router.php b/src/Router.php index b3d4944..d75e0e9 100644 --- a/src/Router.php +++ b/src/Router.php @@ -6,27 +6,38 @@ class Router { protected string $rootUrl; protected string $rootDir; + protected bool $stripPhpExtension = false; + protected bool $stripIndexDotPhp = false; public function __construct() { $this->rootUrl = $_ENV['ROOT_URL']; + // nb: realpath trims the trailing slash $this->rootDir = realpath(__DIR__ . '/../public/'); + $this->stripPhpExtension = EnvVarProcessor::bool($_ENV['URLS_STRIP_PHP_EXTENSION']); + $this->stripIndexDotPhp = EnvVarProcessor::bool($_ENV['URLS_STRIP_INDEX_DOT_PHP']); } /** * @todo add support for generating absolute URLs, etc... * see fe. https://github.com/symfony/routing/blob/7.1/Generator/UrlGeneratorInterface.php + * @param string $fileName the absolute file path. If an empty string is passed, the current execution directory is used * @throws \DomainException */ public function generate(string $fileName, array $queryStringParts = []): string { - $fileName = realpath($fileName); - if (!str_starts_with($fileName, $this->rootDir)) { + $realFileName = realpath($fileName); + if (!str_starts_with($realFileName, $this->rootDir . '/') && $realFileName !== $this->rootDir) { throw new \DomainException("Given file path is outside web root: '$fileName'"); } - $url = $this->rootUrl . substr($fileName, strlen($this->rootDir) + 1); + $url = $this->rootUrl . substr($realFileName, strlen($this->rootDir) + 1); - /// @todo strip trailing `/index.php` based on analysis of $_SERVER + if ($this->stripIndexDotPhp) { + $url = preg_replace('#/index\.php$#', '/', $url); + } + if ($this->stripPhpExtension) { + $url = preg_replace('#\.php$#', '', $url); + } if ($queryStringParts) { $parts = []; @@ -37,4 +48,33 @@ public function generate(string $fileName, array $queryStringParts = []): string } return $url; } + + /** + * @todo add support for absolute urls, etc... + */ + public function match(string $pathInfo): string|false + { + $parts = parse_url($pathInfo); + if (!$parts || !array_key_exists('path', $parts) || $parts['path'] === '' || + array_intersect_key(['scheme' => 0, 'host' => 0, 'port' => 0, 'user' => 0, 'pass' => 0], $parts)) { + return false; + } + + // nb: realpath normalizes excess slashes - no need to ltrim them. It also removes trailing ones + $fileName = realpath($this->rootDir . '/' . $parts['path']); + + if ($fileName === false && $this->stripPhpExtension && !preg_match('#(/|\.php)$#', $parts['path'])) { + $fileName = realpath($this->rootDir . '/' . $parts['path'] . '.php'); + } + + if ($fileName === false || (!str_starts_with($fileName, $this->rootDir . '/') && $fileName !== $this->rootDir)) { + return false; + } + + if (is_dir($fileName) && $this->stripIndexDotPhp && is_file($fileName . '/index.php')) { + $fileName = $fileName . '/index.php'; + } + + return $fileName; + } } diff --git a/src/Security/AnonymousUser.php b/src/Security/AnonymousUser.php new file mode 100644 index 0000000..2091baf --- /dev/null +++ b/src/Security/AnonymousUser.php @@ -0,0 +1,26 @@ +get($this->storageKey, []); + list($tokenIndex, $tokenData) = $this->generateToken($lockTo); + $tokens[$tokenIndex] = $tokenData; + $tokens = $this->pruneTokens($tokens); + $session->set($this->storageKey, $tokens); + return "{$tokenIndex}:{$tokenData['token']}"; + } + + /** + * @param ?string $lockTo + * @return array 1st element: token index (string), 2nd: token data (array) + * @throws \Exception in case not enough randomness can be mustered + * @todo allow to optionally lock down to the current client's IP + */ + protected function generateToken(?string $lockTo): array + { + $index = base64_encode(random_bytes(18)); + $token = base64_encode(random_bytes(33)); + + return [$index, [ + 'created' => time(), + 'token' => $token, + 'lockTo' => $lockTo, + // debugging info + //'uri' => isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : $_SERVER['SCRIPT_NAME'], + ]]; + } + + /** + * Enforce an upper limit on the number of tokens stored by removing the oldest tokens first. + */ + protected function pruneTokens($tokensArray): array + { + if (count($tokensArray) <= $this->maxTokens) { + return $tokensArray; + } + // sort newest first (bigger creation time) + uasort($tokensArray, function (array $a, array $b): int { + return -1 * ($a['created'] <=> $b['created']); + }); + $tokensArray = array_slice($tokensArray, 0, $this->maxTokens, true); + return $tokensArray; + } + + /** + * @throws AntiCSRFException + * @throws \RuntimeException + */ + public function validateToken(string $token, ?string $lockTo = ''): void + { + if ($token === '') { + throw new TokenIsMissingException('Anti-CSRF token not found'); + } + $parts = explode(':', $token); + if (count($parts) !== 2) { + throw new TokenHasInvalidFormatException('Anti-CSRF token has an unsupported format'); + } + $tokenIndex = $parts[0]; + $tokenHash = $parts[1]; + + $session = Session::getInstance(); + $tokens = $session->get($this->storageKey, []); + if (!array_key_exists($tokenIndex, $tokens)) { + throw new TokenIndexNotInSessionException('Anti-CSRF token index not found in the session'); + } + $token = $tokens[$tokenIndex]; + + // remove the token from the session + unset($tokens[$tokenIndex]); + $session->set($this->storageKey, $tokens); + + // note: we do not check that the token creation date make is recent because we store them in the session, + // so their lifetime is naturally limited (based on the session configuration) + + if (!hash_equals($tokenHash, $token['token'])) { + throw new TokenHashMismatchException('Anti-CSRF did not match the stored value'); + } + if ($lockTo !== null && $token['lockTo'] !== null && !hash_equals($lockTo, $token['lockTo'])) { + throw new TokenMatchesWrongFormException('Anti-CSRF token used on the wrong form'); + } + } + + public function purgeTokens(): void + { + // remove all csrf tokens from the session + $session = Session::getInstance(); + $session->set($this->storageKey, []); + } +} diff --git a/src/Security/ClientIPAware.php b/src/Security/ClientIPAware.php new file mode 100644 index 0000000..68983fd --- /dev/null +++ b/src/Security/ClientIPAware.php @@ -0,0 +1,62 @@ +supportsRequest()) { + $this->authenticate(); + } + } + + /** + * @todo implement some logic when/if we'll add support for non-session auth such as eg. basic auth, etc... + */ + protected function supportsRequest(): bool + { + return true; + } + + protected function authenticate(): void + { + $session = Session::getInstance(); + // we avoid calling session_start (triggered by `get`) unless there is a session cookie present, in order to make it + // possible to have the fw run on pages which work both for non and authenticated users without creating sessions + // all the time + if (!$session->cookieIsPresent() && !$session->isStarted()) { + return; + } + $username = $session->get($this->storageKey); + if ($username !== null) { + // we always refresh the user details from the repo, to check if his/her roles or active status have changed + $userProvider = new UserProvider(); + try { + // NB: we do not catch \PDOException - in case f.e. the db connection is down, we let the error bubble all the way up + $user = $userProvider->loadUserByIdentifier($username); + if ($user->isActive()) { + $this->user = $user; + /// @todo here we could add $session->commit() - or use autocommitting sessions + return; + } + } catch (UserNotFoundException $e) { + } + + // the current user either disappeared from the db or got invalidated - clean up the session stuff + $this->logoutUser(true); + + $logger = Logger::getInstance('audit'); + $logger->debug("A session for user '$username' has been forcibly terminated as his/her profile was updated"); + } + } + + public function getUser(): UserInterface + { + return $this->user === null ? new AnonymousUser() : $this->user; + } + + /** + * NB: this does not check for user roles nor active status. Doing that is left to the caller! + */ + public function loginUser(UserInterface $user): void + { + if (!$user->isAuthenticated()) { + throw new \DomainException('Non-authenticated user ' . $user->getUserIdentifier() . ' can not be used for logging in'); + } + + if ($user === $this->user) { + return; + } + + $this->user = $user; + + $session = Session::getInstance(); + $previousUserIdentifier = $session->get($this->storageKey); + if ($user->getUserIdentifier() !== $previousUserIdentifier) { + $session->regenerate(); + + $antiCSRF = new AntiCSRF(); + $antiCSRF->purgeTokens(); + } + $session->set($this->storageKey, $user->getUserIdentifier()); + /// @todo here we could add $session->commit() - or use autocommitting sessions + + if ($user instanceof User) { + $repo = new UserRepository(); + try { + $repo->userLoggedIn($user->username); + } catch (\PDOException) { + $logger = Logger::getInstance('audit'); + $logger->warning("Failed updating last login time for user '{$user->username}'"); + } + } + } + + public function logoutUser(bool $force = false): void + { + if ($this->user === null && !$force) { + return; + } + + $this->user = null; + + $session = Session::getInstance(); + $session->destroySession(); + + /// @todo clean up remember-me tokens if not stored in the Session + /// @todo send Clear-Site-Data header? Investigate more the pros and cons... + } + + /** + * @param UserRole|UserRole[] $roles + * @throws UserNotAuthorizedException + */ + public function require(UserRole|array $roles): void + { + $userRoles = $this->getUser()->getRoles(); + if (!is_array($roles)) { + $roles = array($roles); + } + foreach($roles as $role) { + if (!in_array($role, $userRoles)) { + throw new UserNotAuthorizedException("Current user does not have required role " . $role->value); + } + } + } + + public function displayAdminLoginPage(string $successRedirectUrl): void + { + /// @todo should we give some info or warning if the user is logged in already? Eg. if this is used to display + /// the login form on a page which requires admin perms... + + // avoid browsers and proxies caching the login-form version of the current page - we send he same no-cache headers + // as sent by php when setting session_cache_limiter to nocache + if (!headers_sent()) { + header('Expires: Thu, 19 Nov 1981 08:52:00 GMT'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + } + $router = new Router(); + $tpl = new Templating(); + echo $tpl->render('admin/login.html.twig', [ + 'form' => new LoginForm($router->generate(__DIR__ . '/../../public/admin/login.php'), $successRedirectUrl), + 'urls' => $this->getAdminUrls(), + ]); + } + + /** + * @return string[] + */ + public function getAdminUrls(): array + { + $router = new Router(); + $urls = [ + 'root' => $router->generate(__DIR__ . '/../../public'), + 'home' => $router->generate(__DIR__ . '/../../public/admin/index.php'), + 'login' => $router->generate(__DIR__ . '/../../public/admin/login.php'), + 'logout' => $router->generate(__DIR__ . '/../../public/admin/logout.php'), + 'resetpassword' => $router->generate(__DIR__ . '/../../public/admin/resetpassword.php'), + + ]; + if (EnvVarProcessor::bool($_ENV['ENABLE_FORGOTPASSWORD'])) { + $urls['forgotpassword'] = $router->generate(__DIR__ . '/../../public/admin/forgotpassword.php'); + } + return $urls; + } +} diff --git a/src/Security/PasswordHasher.php b/src/Security/PasswordHasher.php index 23f1627..7c0c966 100644 --- a/src/Security/PasswordHasher.php +++ b/src/Security/PasswordHasher.php @@ -38,7 +38,6 @@ public function __construct() $opsLimit = max((int)@$_ENV['PWD_HASH_OPSLIMIT'], 4, defined('SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE : 4); $memLimit = max((int)@$_ENV['PWD_HASH_MEMLIMIT'], 64 * 1024 * 1024, defined('SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE') ? \SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE : 64 * 1024 * 1024); - $this->options = [ 'cost' => $cost, 'time_cost' => $opsLimit, @@ -94,4 +93,9 @@ public function needsRehash(string $hashedPassword): bool { return password_needs_rehash($hashedPassword, $this->algorithm, $this->options); } + + public function generateRandomString(int $length): string + { + return substr(bin2hex(random_bytes($length)), 0, $length); + } } diff --git a/src/Security/Session.php b/src/Security/Session.php new file mode 100644 index 0000000..0265f28 --- /dev/null +++ b/src/Security/Session.php @@ -0,0 +1,92 @@ +sessionOptions = [ + 'use_strict_mode' => 1, 'use_cookies' => 1, 'use_only_cookies' => 1, 'cookie_httponly' => 1, + 'cookie_samesite' => 'Strict'/*, 'cache_limiter' => '', 'cache_expire' => 0*/, 'use_trans_sid' => 0, 'lazy_write' => 1, + ]; + } + + public function isStarted(): bool + { + return $this->sessionStarted; + } + + public function cookieIsPresent(): bool + { + return array_key_exists(session_name(), $_COOKIE); + } + + public function regenerate(): void + { + /// @todo Should we delete the previous session data, passing in $true, or keep the old session around? See example 2 at + /// https://www.php.net/manual/en/function.session-regenerate-id.php#refsect1-function.session-regenerate-id-examples + /// for the recommended way to generate new session ids while avoiding issues with unstable networks and + /// race conditions between requests + session_regenerate_id(); + } + + public function destroySession(): void + { + if (\PHP_SESSION_ACTIVE === session_status()) { + $_SESSION = []; + // no need to call session_write_close() here, as or does session_destroy triggers that already + session_destroy(); + } + + $this->sessionStarted = false; + + // Expire the session cookie. + // Note that, in theory at least, this is not required when session.strict_mode is enabled (which we _try_ to force), + // as subsequent requests with the same session id cookie will not trigger creation of a session. + // We prefer taking a belt-and-suspenders approach, and leave no dead cookies on the browser. + $sessionName = session_name(); + if (isset($_COOKIE[$sessionName])) { + $params = session_get_cookie_params(); + unset($params['lifetime']); + setcookie($sessionName, '', $params); + } + } + + /** + * Saves data and unlocks the session + * @throws \RuntimeException + */ + public function commit(): void + { + $this->doCommit(); + } +} diff --git a/src/Security/UserInterface.php b/src/Security/UserInterface.php new file mode 100644 index 0000000..827a4fb --- /dev/null +++ b/src/Security/UserInterface.php @@ -0,0 +1,26 @@ +userRepository = new UserRepository(); + } + + /** + * @throws UserNotFoundException + * @throws \PDOException + */ + public function loadUserByIdentifier(string $identifier): User + { + $user = $this->userRepository->fetchUser($identifier); + if ($user === null) { + throw new UserNotFoundException("No such user: $identifier"); + } + return $user; + } +} diff --git a/src/Security/UserProviderInterface.php b/src/Security/UserProviderInterface.php new file mode 100644 index 0000000..c15cdfe --- /dev/null +++ b/src/Security/UserProviderInterface.php @@ -0,0 +1,8 @@ +userProvider = new UserProvider(); + $this->passwordHasher = new PasswordHasher(); + $this->logger = Logger::getInstance('audit'); + } + + /** + * @throws AuthenticationException + */ + public function authenticate(string $username, #[\SensitiveParameter] string $password): UserInterface + { + try { + $user = $this->userProvider->loadUserByIdentifier($username); + } catch (UserNotFoundException $e) { + $this->logger->info("User '$username' failed logging in: not found"); + throw $e; + } + if (!$this->passwordHasher->verify($user->passwordHash, $password)) { + $this->logger->info("User '$username' failed logging in: bad password"); + throw new BadCredentialsException('Invalid username/password'); + } + if (!$user->isActive()) { + $this->logger->info("User '$username' failed logging in: it is not active"); + throw new AccountExpiredException('User account is not active'); + } + + Firewall::getInstance()->loginUser($user); + + $this->logger->debug("User '$username' logged in"); + + return $user; + } +} diff --git a/src/Singleton.php b/src/Singleton.php new file mode 100644 index 0000000..6a35231 --- /dev/null +++ b/src/Singleton.php @@ -0,0 +1,17 @@ +setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + // this is necessary for ex. for queries using bound params for offset, limit on mariadb... + self::$dbh->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + + + $dbType = self::$dbh->getAttribute(PDO::ATTR_DRIVER_NAME); + switch ($dbType) { + case 'sqlite': + $query = "PRAGMA foreign_keys = ON"; + self::$dbh->query($query); + break; + } + } + } + + /** + * Check if a table exists in the database + * + * @param string $tableName + * @return bool + * @throws \DomainException in case of unsupported database type + * @throws \PDOException + */ + protected function tableExists(string $tableName): bool + { + if (!is_array(self::$tableDefs)) { + self::$tableDefs = $this->listTables(); + } + return in_array($tableName, self::$tableDefs); + } + + /** + * List all db tables accessible to the current user. + * + * @return string[] value has to be the table name + * @throws \DomainException in case of unsupported database type + * @throws \PDOException + * @todo decide if we want to include or exclude views and be consistent about it + */ + protected function listTables(): array + { + $dbType = self::$dbh->getAttribute(PDO::ATTR_DRIVER_NAME); + // Queries taken from Doctrine DBAL + $query = match ($dbType) { + 'mysql' => "SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'", + 'pgsql' => "SELECT quote_ident(table_name) AS table_name + FROM information_schema.tables + WHERE table_schema NOT LIKE 'pg\_%' + AND table_schema != 'information_schema' + AND table_name != 'geometry_columns' + AND table_name != 'spatial_ref_sys' + AND table_type != 'VIEW'", + 'sqlite' => "SELECT name FROM sqlite_master + WHERE type = 'table' + AND name != 'sqlite_sequence' + AND name != 'geometry_columns' + AND name != 'spatial_ref_sys' + UNION ALL + SELECT name + FROM sqlite_temp_master + WHERE type = 'table'", + default => throw new \DomainException("Database type '$dbType' is not supported"), + }; + + return self::$dbh->query($query)->fetchAll(PDO::FETCH_COLUMN, 0); + } +} diff --git a/src/Storage/Database/Column.php b/src/Storage/Database/Column.php new file mode 100644 index 0000000..ba6feb0 --- /dev/null +++ b/src/Storage/Database/Column.php @@ -0,0 +1,9 @@ +tableExists) { + return false; + } + + if ($this->tableExists($this->tableName)) { + $this->tableExists = true; + return false; + } + + $this->createTable(); + + $this->tableExists = true; + return true; + } + + /** + * A few notes on the SQLite type system (full docs at https://www.sqlite.org/datatype3.html), for the unwary: + * - any column can hold any type! + * - type juggling is in effect, but type-conversion rules are not the same as in php. Notably, expressions have no type! + * - there is no 'bool' or 'date' column/data type. Columns defined as such get a 'numeric' preferential type (aka 'affinity') + * - the length limit on varchar columns is ignored + * @throws \DomainException in case of unsupported field constraint + * @throws \PDOException + * @todo should we disallow '' as value for all non-null string fields (or via a custom constraint)? Sqlite f.e. + * supports `CHECK()`, or we could use the cross-database attribute PDO::NULL_EMPTY_STRING + */ + protected function createTable(): void + { + $dbType = self::$dbh->getAttribute(\PDO::ATTR_DRIVER_NAME); + + $query = 'CREATE TABLE ' . $this->tableName . ' ('; + foreach ($this->fields as $colName => $f) { + $type = $f->type; + if (isset($f->constraints[FC::Autoincrement]) && $f->constraints[FC::Autoincrement] && $dbType == 'pgsql') { + $type = 'serial'; + } + $query .= $colName . ' ' . $type . ' '; + $constraints = $f->constraints; + if ($type !== 'serial' && isset($constraints[FC::Length])) { + $query .= '(' . $f->constraints[FC::Length] . ') '; + unset($constraints[FC::Length]); + } + foreach($constraints as $cn => $cv) { + switch($cn) { + case FC::PK: + if ($cv) { + $query .= 'PRIMARY KEY '; + } + break; + case FC::Autoincrement: + if ($cv) { + switch ($dbType) { + case 'mysql': + $query .= 'AUTO_INCREMENT '; + break; + case 'pgsql': + break; + case 'sqlite': + $query .= 'AUTOINCREMENT '; + break; + default: + throw new \DomainException("Database type '$dbType' is not supported"); + } + } + break; + case FC::NotNull: + if ($cv) { + $query .= 'NOT NULL '; + } + break; + case FC::Unique: + if ($cv) { + $query .= 'UNIQUE '; + } + break; + case FC::Default: + if ($cv !== null) { + $query .= 'DEFAULT ' . $cv . ' '; + } + break; + default: + throw new \DomainException("Unsupported Field constraint '$cn'"); + } + } + $query = substr($query, 0, -1) . ', '; + } + + /// @todo figure out how to enforce the fact that the referenced table has been already created + foreach ($this->foreignKeys as $fk) { + $query .= 'FOREIGN KEY (' . implode(', ', $fk->columns). ') REFERENCES ' . $fk->parentTable . '(' . + implode(', ', $fk->parentColumns). ') ON DELETE ' . $fk->onDelete->value . ' ON UPDATE ' . + $fk->onUpdate->value . ', '; + } + + $query = substr($query, 0, -2) . ')'; + + self::$dbh->exec($query); + + foreach ($this->indexes as $name => $idx) { + $query = 'CREATE ' . ($idx->unique ? 'UNIQUE ' : '') . 'INDEX ' . $name . ' ON ' . $this->tableName . '(' . + implode(', ', $idx->columns) . ')'; + self::$dbh->exec($query); + } + } +} diff --git a/src/Storage/Redis.php b/src/Storage/Redis.php new file mode 100644 index 0000000..b7efb91 --- /dev/null +++ b/src/Storage/Redis.php @@ -0,0 +1,28 @@ +connect($_ENV['REDIS_HOST'], (int)$_ENV['REDIS_PORT']); + if (@$_ENV['REDIS_PASSWORD'] != '') { + self::$rh->auth($_ENV['REDIS_PASSWORD']); + } + } + } +} diff --git a/src/Storage/Session.php b/src/Storage/Session.php new file mode 100644 index 0000000..4279923 --- /dev/null +++ b/src/Storage/Session.php @@ -0,0 +1,106 @@ +sessionStarted) { + $this->startSession(); + } + + $_SESSION[$key] = $value; + + if ($this->doAutoCommit) { + $this->doCommit(); + } + } + + /** + * NB: this will cause the session to start if it was not started already! + * @todo we could cache the whole of $_SESSION in memory so that we can avoid further calls to session_start on later + * calls to get (and either add a $forceRefresh argument, or a separate `refresh` method). This is esp. + * useful when in autocommit mode + * @throws \RuntimeException if the session was not yet started and either http headers have already been sent, or + * (presumably) the session storage fails + */ + public function get(string|int $key, mixed $default = null): mixed + { + if (!$this->sessionStarted) { + $this->startSession(); + } + + if (array_key_exists($key, $_SESSION)) { + $default = $_SESSION[$key]; + } + + if ($this->doAutoCommit) { + $this->doCommit(); + } + + return $default; + } + + /** + * @throws \RuntimeException if the session was not yet started and either http headers have already been sent, or + * (presumably) the session storage fails + */ + protected function startSession(): void + { + if (\PHP_SESSION_NONE === session_status()) { + + // we throw to avoid the php warning 'Session cannot be started after headers have already been sent' + // which would be generated later by calling `session start` + if (headers_sent()) { + throw new \RuntimeException('Session cannot be started after headers have already been sent'); + } + + // NB: in case we did not enforce use_strict_mode, we could look if there is a cookie matching `session_name()`. + // If there is, validate that the session_id matches a regexp like `/^[a-zA-Z0-9,-]{22,250}$/` (but built upon + // live values of session.sid_bits_per_character and session.sid_length) and if it does not, call + // `session_id(session_create_id())` and log the event. See Sf NativeSessionStorage::start for an explanation + + /// @todo once we have improved `regenerate` so that it keeps around the old session and adds specific + /// data to it, check here for its presence + + // NB: this sometimes generates a php warning `ps_files_cleanup_dir: opendir(/var/lib/php/sessions) failed: Permission denied` + if (!session_start($this->sessionOptions)) { + throw new \RuntimeException('Failed to start the session'); + } + } + + $this->sessionStarted = true; + } + + /** + * Sets/gets the autocommit mode + */ + public function autoCommit(?bool $autoCommit): bool + { + if ($autoCommit !== null) { + $this->doAutoCommit = $autoCommit; + } + return $this->doAutoCommit; + } + + /** + * Saves data and unlocks the session + * @throws \RuntimeException + */ + protected function doCommit(): void + { + if (!session_write_close()) { + throw new \RuntimeException('Failed to save the session'); + } + $this->sessionStarted = false; + } +} diff --git a/src/Templating.php b/src/Templating.php index dceb2a9..69a9e25 100644 --- a/src/Templating.php +++ b/src/Templating.php @@ -7,7 +7,7 @@ class Templating { - protected $twig; + protected TwigEnvironment $twig; public function __construct() {