diff --git a/app/Bootstrap.php b/app/Bootstrap.php index 3aad930..cb937ac 100644 --- a/app/Bootstrap.php +++ b/app/Bootstrap.php @@ -4,28 +4,57 @@ namespace App; +use Nette; use Nette\Bootstrap\Configurator; +/** + * Bootstrap class initializes application environment and DI container. + */ class Bootstrap { - public static function boot(): Configurator + private Configurator $configurator; + private string $rootDir; + + + public function __construct() + { + $this->rootDir = dirname(__DIR__); + + // The configurator is responsible for setting up the application environment and services. + // Learn more at https://doc.nette.org/en/bootstrap + $this->configurator = new Configurator; + + // Set the directory for temporary files generated by Nette (e.g. compiled templates) + $this->configurator->setTempDirectory($this->rootDir . '/temp'); + } + + + public function bootWebApplication(): Nette\DI\Container { - $configurator = new Configurator; - $appDir = dirname(__DIR__); + $this->initializeEnvironment(); + $this->setupContainer(); + return $this->configurator->createContainer(); + } - //$configurator->setDebugMode('secret@23.75.345.200'); // enable for your remote IP - $configurator->enableTracy($appDir . '/log'); - $configurator->setTempDirectory($appDir . '/temp'); + public function initializeEnvironment(): void + { + // Nette is smart, and the development mode turns on automatically, + // or you can enable for a specific IP address it by uncommenting the following line: + // $this->configurator->setDebugMode('secret@23.75.345.200'); - $configurator->createRobotLoader() - ->addDirectory(__DIR__) - ->register(); + // Enables Tracy: the ultimate "swiss army knife" debugging tool. + // Learn more about Tracy at https://tracy.nette.org + $this->configurator->enableTracy($this->rootDir . '/log'); + } - $configurator->addConfig($appDir . '/config/common.neon'); - $configurator->addConfig($appDir . '/config/services.neon'); - return $configurator; + private function setupContainer(): void + { + // Load configuration files + $configDir = $this->rootDir . '/config'; + $this->configurator->addConfig($configDir . '/common.neon'); + $this->configurator->addConfig($configDir . '/services.neon'); } } diff --git a/app/Core/RouterFactory.php b/app/Core/RouterFactory.php index 3b1a285..369c55e 100644 --- a/app/Core/RouterFactory.php +++ b/app/Core/RouterFactory.php @@ -12,10 +12,14 @@ final class RouterFactory { use Nette\StaticClass; + /** + * Creates the main application router with defined routes. + */ public static function createRouter(): RouteList { $router = new RouteList; - $router->addRoute('/[/]', 'Home:default'); + // Default route that maps to the Dashboard + $router->addRoute('/', 'Dashboard:default'); return $router; } } diff --git a/app/Model/UserFacade.php b/app/Model/UserFacade.php new file mode 100644 index 0000000..9ac2dea --- /dev/null +++ b/app/Model/UserFacade.php @@ -0,0 +1,95 @@ +database->table(self::TableName) + ->where(self::ColumnName, $username) + ->fetch(); + + // Authentication checks + if (!$row) { + throw new Nette\Security\AuthenticationException('The username is incorrect.', self::IdentityNotFound); + + } elseif (!$this->passwords->verify($password, $row[self::ColumnPasswordHash])) { + throw new Nette\Security\AuthenticationException('The password is incorrect.', self::InvalidCredential); + + } elseif ($this->passwords->needsRehash($row[self::ColumnPasswordHash])) { + $row->update([ + self::ColumnPasswordHash => $this->passwords->hash($password), + ]); + } + + // Return user identity without the password hash + $arr = $row->toArray(); + unset($arr[self::ColumnPasswordHash]); + return new Nette\Security\SimpleIdentity($row[self::ColumnId], $row[self::ColumnRole], $arr); + } + + + /** + * Add a new user to the database. + * Throws a DuplicateNameException if the username is already taken. + */ + public function add(string $username, string $email, string $password): void + { + // Validate the email format + Nette\Utils\Validators::assert($email, 'email'); + + // Attempt to insert the new user into the database + try { + $this->database->table(self::TableName)->insert([ + self::ColumnName => $username, + self::ColumnPasswordHash => $this->passwords->hash($password), + self::ColumnEmail => $email, + ]); + } catch (Nette\Database\UniqueConstraintViolationException $e) { + throw new DuplicateNameException; + } + } +} + + +/** + * Custom exception for duplicate usernames. + */ +class DuplicateNameException extends \Exception +{ +} diff --git a/app/Presentation/@layout.latte b/app/Presentation/@layout.latte index d3b9933..a1cd9a7 100644 --- a/app/Presentation/@layout.latte +++ b/app/Presentation/@layout.latte @@ -1,17 +1,28 @@ +{import 'form-bootstrap5.latte'} + - {ifset title}{include title|stripHtml} | {/ifset}Nette Web + {* Page title with optional prefix from the child template *} + {ifset title}{include title|stripHtml} | {/ifset}User Login Example + + {* Link to the Bootstrap stylesheet for styling *} + -
{$flash->message}
+
+ {* Flash messages display block *} +
{$flash->message}
- {include content} + {* Main content of the child template goes here *} + {include content} +
+ {* Scripts block; by default includes Nette Forms script for validation *} {block scripts} {/block} diff --git a/app/Presentation/Accessory/FormFactory.php b/app/Presentation/Accessory/FormFactory.php new file mode 100644 index 0000000..96d7203 --- /dev/null +++ b/app/Presentation/Accessory/FormFactory.php @@ -0,0 +1,34 @@ +user->isLoggedIn()) { + $form->addProtection(); + } + return $form; + } +} diff --git a/app/Presentation/Accessory/LatteExtension.php b/app/Presentation/Accessory/LatteExtension.php deleted file mode 100644 index 6d6721f..0000000 --- a/app/Presentation/Accessory/LatteExtension.php +++ /dev/null @@ -1,22 +0,0 @@ -onStartup[] = function () { + $user = $this->getUser(); + // If the user isn't logged in, redirect them to the sign-in page + if ($user->isLoggedIn()) { + return; + } elseif ($user->getLogoutReason() === $user::LogoutInactivity) { + $this->flashMessage('You have been signed out due to inactivity. Please sign in again.'); + $this->redirect('Sign:in', ['backlink' => $this->storeRequest()]); + } else { + $this->redirect('Sign:in'); + } + }; + } +} diff --git a/app/Presentation/Dashboard/DashboardPresenter.php b/app/Presentation/Dashboard/DashboardPresenter.php new file mode 100644 index 0000000..cf64342 --- /dev/null +++ b/app/Presentation/Dashboard/DashboardPresenter.php @@ -0,0 +1,19 @@ +Dashboard + +

If you see this page, it means you have successfully logged in.

+ +

(Sign out)

diff --git a/app/Presentation/Error/Error4xx/403.latte b/app/Presentation/Error/Error4xx/403.latte deleted file mode 100644 index de00328..0000000 --- a/app/Presentation/Error/Error4xx/403.latte +++ /dev/null @@ -1,7 +0,0 @@ -{block content} -

Access Denied

- -

You do not have permission to view this page. Please try contact the web -site administrator if you believe you should be able to view this page.

- -

error 403

diff --git a/app/Presentation/Error/Error4xx/404.latte b/app/Presentation/Error/Error4xx/404.latte deleted file mode 100644 index 022001c..0000000 --- a/app/Presentation/Error/Error4xx/404.latte +++ /dev/null @@ -1,8 +0,0 @@ -{block content} -

Page Not Found

- -

The page you requested could not be found. It is possible that the address is -incorrect, or that the page no longer exists. Please use a search engine to find -what you are looking for.

- -

error 404

diff --git a/app/Presentation/Error/Error4xx/410.latte b/app/Presentation/Error/Error4xx/410.latte deleted file mode 100644 index 99bde92..0000000 --- a/app/Presentation/Error/Error4xx/410.latte +++ /dev/null @@ -1,6 +0,0 @@ -{block content} -

Page Not Found

- -

The page you requested has been taken off the site. We apologize for the inconvenience.

- -

error 410

diff --git a/app/Presentation/Error/Error4xx/4xx.latte b/app/Presentation/Error/Error4xx/4xx.latte deleted file mode 100644 index 49e6127..0000000 --- a/app/Presentation/Error/Error4xx/4xx.latte +++ /dev/null @@ -1,6 +0,0 @@ -{block content} -

Oops...

- -

Your browser sent a request that this server could not understand or process.

- -

error {$httpCode}

diff --git a/app/Presentation/Error/Error4xx/Error4xxPresenter.php b/app/Presentation/Error/Error4xx/Error4xxPresenter.php deleted file mode 100644 index 3418679..0000000 --- a/app/Presentation/Error/Error4xx/Error4xxPresenter.php +++ /dev/null @@ -1,27 +0,0 @@ -getCode(); - $file = is_file($file = __DIR__ . "/$code.latte") - ? $file - : __DIR__ . '/4xx.latte'; - $this->template->httpCode = $code; - $this->template->setFile($file); - } -} diff --git a/app/Presentation/Error/Error5xx/500.phtml b/app/Presentation/Error/Error5xx/500.phtml deleted file mode 100644 index a2b900c..0000000 --- a/app/Presentation/Error/Error5xx/500.phtml +++ /dev/null @@ -1,27 +0,0 @@ - - - -Server Error - - - -
-
-

Server Error

- -

We're sorry! The server encountered an internal error and - was unable to complete your request. Please try again later.

- -

error 500

-
-
- - diff --git a/app/Presentation/Error/Error5xx/503.phtml b/app/Presentation/Error/Error5xx/503.phtml deleted file mode 100644 index f123919..0000000 --- a/app/Presentation/Error/Error5xx/503.phtml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - -Site is temporarily down for maintenance - -

We're Sorry

- -

The site is temporarily down for maintenance. Please try again in a few minutes.

diff --git a/app/Presentation/Error/Error5xx/Error5xxPresenter.php b/app/Presentation/Error/Error5xx/Error5xxPresenter.php deleted file mode 100644 index dc678e6..0000000 --- a/app/Presentation/Error/Error5xx/Error5xxPresenter.php +++ /dev/null @@ -1,39 +0,0 @@ -getParameter('exception'); - $this->logger->log($exception, ILogger::EXCEPTION); - - // Display a generic error message to the user - return new Responses\CallbackResponse(function (Http\IRequest $httpRequest, Http\IResponse $httpResponse): void { - if (preg_match('#^text/html(?:;|$)#', (string) $httpResponse->getHeader('Content-Type'))) { - require __DIR__ . '/500.phtml'; - } - }); - } -} diff --git a/app/Presentation/Home/HomePresenter.php b/app/Presentation/Home/HomePresenter.php deleted file mode 100644 index 3b212c9..0000000 --- a/app/Presentation/Home/HomePresenter.php +++ /dev/null @@ -1,12 +0,0 @@ - -

Congratulations!

- - -
-

You have successfully created your Nette Web project.

- -

- If you are exploring Nette for the first time, you should read the - Quick Start, documentation, - blog and forum.

- -

We hope you enjoy Nette!

-
- - diff --git a/app/Presentation/Sign/SignPresenter.php b/app/Presentation/Sign/SignPresenter.php new file mode 100644 index 0000000..5e90059 --- /dev/null +++ b/app/Presentation/Sign/SignPresenter.php @@ -0,0 +1,109 @@ +formFactory->create(); + $form->addText('username', 'Username:') + ->setRequired('Please enter your username.'); + + $form->addPassword('password', 'Password:') + ->setRequired('Please enter your password.'); + + $form->addSubmit('send', 'Sign in'); + + // Handle form submission + $form->onSuccess[] = function (Form $form, \stdClass $data): void { + try { + // Attempt to login user + $this->getUser()->login($data->username, $data->password); + $this->restoreRequest($this->backlink); + $this->redirect('Dashboard:'); + } catch (Nette\Security\AuthenticationException) { + $form->addError('The username or password you entered is incorrect.'); + } + }; + + return $form; + } + + + /** + * Create a sign-up form with fields for username, email, and password. + * On successful submission, the user is redirected to the dashboard. + */ + protected function createComponentSignUpForm(): Form + { + $form = $this->formFactory->create(); + $form->addText('username', 'Pick a username:') + ->setRequired('Please pick a username.'); + + $form->addEmail('email', 'Your e-mail:') + ->setRequired('Please enter your e-mail.'); + + $form->addPassword('password', 'Create a password:') + ->setOption('description', sprintf('at least %d characters', $this->userFacade::PasswordMinLength)) + ->setRequired('Please create a password.') + ->addRule($form::MinLength, null, $this->userFacade::PasswordMinLength); + + $form->addSubmit('send', 'Sign up'); + + // Handle form submission + $form->onSuccess[] = function (Form $form, \stdClass $data): void { + try { + // Attempt to register a new user + $this->userFacade->add($data->username, $data->email, $data->password); + $this->redirect('Dashboard:'); + } catch (DuplicateNameException) { + // Handle the case where the username is already taken + $form['username']->addError('Username is already taken.'); + } + }; + + return $form; + } + + + /** + * Logs out the currently authenticated user. + */ + public function actionOut(): void + { + $this->getUser()->logout(); + } +} diff --git a/app/Presentation/Sign/in.latte b/app/Presentation/Sign/in.latte new file mode 100644 index 0000000..f6f99d0 --- /dev/null +++ b/app/Presentation/Sign/in.latte @@ -0,0 +1,8 @@ +{* The sign-in page *} + +{block content} +

Sign In

+ +{include bootstrap-form signInForm} + +

Don't have an account yet? Sign up.

diff --git a/app/Presentation/Sign/out.latte b/app/Presentation/Sign/out.latte new file mode 100644 index 0000000..3607ad3 --- /dev/null +++ b/app/Presentation/Sign/out.latte @@ -0,0 +1,6 @@ +{* The sign-out page *} + +{block content} +

You have been signed out

+ +

Sign in to another account

diff --git a/app/Presentation/Sign/up.latte b/app/Presentation/Sign/up.latte new file mode 100644 index 0000000..db6e7e4 --- /dev/null +++ b/app/Presentation/Sign/up.latte @@ -0,0 +1,8 @@ +{* The sign-up page *} + +{block content} +

Sign Up

+ +{include bootstrap-form signUpForm} + +

Already have an account? Log in.

diff --git a/app/Presentation/form-bootstrap5.latte b/app/Presentation/form-bootstrap5.latte new file mode 100644 index 0000000..f77c8cf --- /dev/null +++ b/app/Presentation/form-bootstrap5.latte @@ -0,0 +1,61 @@ +{* Generic form template for Bootstrap v5 *} + +{define bootstrap-form, $name} +
+ {* List for form-level error messages *} +
    +
  • {$error}
  • +
+ + {include controls $form->getControls()} +
+{/define} + + +{define local controls, array $controls} + {* Loop over form controls and render each one *} +
+ + {* Label for the control *} +
{label $control /}
+ +
+ {include control $control} + {if $control->getOption(type) === button} + {while $iterator->nextValue?->getOption(type) === button} + {input $iterator->nextValue class => "btn btn-secondary"} + {do $iterator->next()} + {/while} + {/if} + + {* Display control-level errors or descriptions, if present *} + {$control->error} + {$control->getOption(description)} +
+
+{/define} + + +{define local control, Nette\Forms\Controls\BaseControl $control} + {* Conditionally render controls based on their type with appropriate Bootstrap classes *} + {if $control->getOption(type) in [text, select, textarea, datetime, file]} + {input $control class => form-control} + + {elseif $control->getOption(type) === button} + {input $control class => "btn btn-primary"} + + {elseif $control->getOption(type) in [checkbox, radio]} + {var $items = $control instanceof Nette\Forms\Controls\Checkbox ? [''] : $control->getItems()} +
+ {input $control:$key class => form-check-input}{label $control:$key class => form-check-label /} +
+ + {elseif $control->getOption(type) === color} + {input $control class => "form-control form-control-color"} + + {else} + {input $control} + {/if} +{/define} diff --git a/bin/.gitignore b/bin/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/bin/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/bin/create-user.php b/bin/create-user.php new file mode 100644 index 0000000..3bfe94a --- /dev/null +++ b/bin/create-user.php @@ -0,0 +1,31 @@ +bootWebApplication(); + +if (!isset($argv[3])) { + echo ' +Add new user to database. + +Usage: create-user.php +'; + exit(1); +} + +[, $name, $email, $password] = $argv; + +$manager = $container->getByType(App\Model\UserFacade::class); + +try { + $manager->add($name, $email, $password); + echo "User $name was added.\n"; + +} catch (App\Model\DuplicateNameException $e) { + echo "Error: duplicate name.\n"; + exit(1); +} diff --git a/composer.json b/composer.json index 477c968..1ef4482 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,17 @@ { - "name": "nette/web-project", - "description": "Nette: Standard Web Project", - "keywords": ["nette"], + "name": "nette-examples/user-authentication", "type": "project", - "license": ["MIT", "BSD-3-Clause", "GPL-2.0", "GPL-3.0"], + "license": "BSD-3-Clause", + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], "require": { "php": ">= 8.1", "nette/application": "^3.2.3", @@ -13,26 +21,18 @@ "nette/di": "^3.2", "nette/forms": "^3.2", "nette/http": "^3.3", - "nette/mail": "^4.0", - "nette/robot-loader": "^4.0", "nette/security": "^3.2", "nette/utils": "^4.0", "latte/latte": "^3.0", "tracy/tracy": "^2.10" }, "require-dev": { - "nette/tester": "^2.5", - "symfony/thanks": "^1" + "nette/tester": "^2.5" }, "autoload": { "psr-4": { "App\\": "app" } }, - "minimum-stability": "stable", - "config": { - "allow-plugins": { - "symfony/thanks": true - } - } + "minimum-stability": "stable" } diff --git a/config/common.neon b/config/common.neon index 02cee12..22775f3 100644 --- a/config/common.neon +++ b/config/common.neon @@ -1,27 +1,15 @@ +# Application parameters and settings. See https://doc.nette.org/configuring + parameters: application: - errorPresenter: - 4xx: Error:Error4xx - 5xx: Error:Error5xx + # Presenter mapping pattern mapping: App\Presentation\*\**Presenter database: - dsn: 'sqlite::memory:' + # SQLite database source location + dsn: 'sqlite:%rootDir%/data/db.sqlite' user: password: - - -latte: - strictTypes: yes - strictParsing: yes - extensions: - - App\Presentation\Accessory\LatteExtension - - -di: - export: - parameters: no - tags: no diff --git a/config/services.neon b/config/services.neon index 67302ee..e21b7c9 100644 --- a/config/services.neon +++ b/config/services.neon @@ -1,3 +1,5 @@ +# Service registrations. See https://doc.nette.org/dependency-injection/services + services: - App\Core\RouterFactory::createRouter diff --git a/data/db.sqlite b/data/db.sqlite new file mode 100644 index 0000000..ebbe939 Binary files /dev/null and b/data/db.sqlite differ diff --git a/data/mysql.sql b/data/mysql.sql new file mode 100644 index 0000000..2d49ff9 --- /dev/null +++ b/data/mysql.sql @@ -0,0 +1,9 @@ +CREATE TABLE `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(100) NOT NULL, + `password` varchar(100) NOT NULL, + `email` varchar(100) NOT NULL, + `role` varchar(100), + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/readme.md b/readme.md index 7a891dc..8292db1 100644 --- a/readme.md +++ b/readme.md @@ -1,52 +1,47 @@ -Nette Web Project -================= +User Authentication (Nette example) +=================================== -Welcome to the Nette Web Project! This is a basic skeleton application built using -[Nette](https://nette.org), ideal for kick-starting your new web projects. +Example of user management. -Nette is a renowned PHP web development framework, celebrated for its user-friendliness, -robust security, and outstanding performance. It's among the safest choices -for PHP frameworks out there. - -If Nette helps you, consider supporting it by [making a donation](https://nette.org/donate). -Thank you for your generosity! - - -Requirements ------------- - -This Web Project is compatible with Nette 3.2 and requires PHP 8.1. +- User login, registration and logout (`SignPresenter`) +- Command line registration (`bin/create-user.php`) +- Authentication using database table (`UserFacade`) +- Password hashing +- Presenter requiring authentication (`DashboardPresenter`) using the `RequireLoggedUser` trait +- Rendering forms using Bootstrap CSS framework +- Automatic CSRF protection using a token when the user is logged in (`FormFactory`) +- Separation of form factories into independent classes (`SignInFormFactory`, `SignUpFormFactory`) +- Return to previous page after login (`SignPresenter::$backlink`) Installation ------------ -To install the Web Project, Composer is the recommended tool. If you're new to Composer, -follow [these instructions](https://doc.nette.org/composer). Then, run: - - composer create-project nette/web-project path/to/install - cd path/to/install - -Ensure the `temp/` and `log/` directories are writable. - - -Web Server Setup ----------------- +```shell +git clone https://github.com/nette-examples/user-authentication +cd user-authentication +composer install +``` -To quickly dive in, use PHP's built-in server: +Make directories `data/`, `temp/` and `log/` writable. - php -S localhost:8000 -t www +By default, SQLite is used as the database which is located in the `data/db.sqlite` file. If you would like to switch to a different database, configure access in the `config/local.neon` file: -Then, open `http://localhost:8000` in your browser to view the welcome page. +```neon +database: + dsn: 'mysql:host=127.0.0.1;dbname=***' + user: *** + password: *** +``` -For Apache or Nginx users, configure a virtual host pointing to your project's `www/` directory. +And then create the `users` table using SQL statements in the [data/mysql.sql](data/mysql.sql) file. -**Important Note:** Ensure `app/`, `config/`, `log/`, and `temp/` directories are not web-accessible. -Refer to [security warning](https://nette.org/security-warning) for more details. +The simplest way to get started is to start the built-in PHP server in the root directory of your project: +```shell +php -S localhost:8000 www/index.php +``` -Minimal Skeleton ----------------- +Then visit `http://localhost:8000` in your browser to see the welcome page. -For demonstrating issues or similar tasks, rather than starting a new project, use -this [minimal skeleton](https://github.com/nette/web-project/tree/minimal). +It requires PHP version 8.1 or newer. diff --git a/www/index.php b/www/index.php index 466a988..a6612cf 100644 --- a/www/index.php +++ b/www/index.php @@ -2,9 +2,17 @@ declare(strict_types=1); -require __DIR__ . '/../vendor/autoload.php'; +// Load the Composer autoloader +if (@!include __DIR__ . '/../vendor/autoload.php') { + die('Install Nette using `composer update`'); +} -$configurator = App\Bootstrap::boot(); -$container = $configurator->createContainer(); +// Initialize the application environment +$bootstrap = new App\Bootstrap; + +// Create the Dependency Injection container +$container = $bootstrap->bootWebApplication(); + +// Start the application and handle the incoming request $application = $container->getByType(Nette\Application\Application::class); $application->run();