diff --git a/.gitignore b/.gitignore index 22d0d82..7579f74 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ vendor +composer.lock diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/app/Bootstrap.php b/app/Bootstrap.php new file mode 100644 index 0000000..7a39742 --- /dev/null +++ b/app/Bootstrap.php @@ -0,0 +1,39 @@ +setDebugMode('secret@23.75.345.200'); + + // Enables Tracy: the ultimate "swiss army knife" debugging tool. + // Learn more about Tracy at https://tracy.nette.org + $configurator->enableTracy($appDir . '/log'); + + // Set the directory for temporary files generated by Nette (e.g. compiled templates) + $configurator->setTempDirectory($appDir . '/temp'); + + // Add configuration files + $configurator->addConfig($appDir . '/config/common.neon'); + $configurator->addConfig($appDir . '/config/services.neon'); + + return $configurator; + } +} diff --git a/app/Core/RouterFactory.php b/app/Core/RouterFactory.php new file mode 100644 index 0000000..369c55e --- /dev/null +++ b/app/Core/RouterFactory.php @@ -0,0 +1,25 @@ +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/UI/@layout.latte b/app/UI/@layout.latte new file mode 100644 index 0000000..8d9e02e --- /dev/null +++ b/app/UI/@layout.latte @@ -0,0 +1,30 @@ +{import 'form-bootstrap5.latte'} + + + + + + + + {* 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 messages display block *} +
{$flash->message}
+ + {* 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/UI/Accessory/FormFactory.php b/app/UI/Accessory/FormFactory.php new file mode 100644 index 0000000..2e36176 --- /dev/null +++ b/app/UI/Accessory/FormFactory.php @@ -0,0 +1,34 @@ +user->isLoggedIn()) { + $form->addProtection(); + } + return $form; + } +} diff --git a/app/UI/Accessory/RequireLoggedUser.php b/app/UI/Accessory/RequireLoggedUser.php new file mode 100644 index 0000000..b8717fe --- /dev/null +++ b/app/UI/Accessory/RequireLoggedUser.php @@ -0,0 +1,33 @@ +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/UI/Dashboard/DashboardPresenter.php b/app/UI/Dashboard/DashboardPresenter.php new file mode 100644 index 0000000..0b48715 --- /dev/null +++ b/app/UI/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/UI/Sign/SignPresenter.php b/app/UI/Sign/SignPresenter.php new file mode 100644 index 0000000..320ac1b --- /dev/null +++ b/app/UI/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/UI/Sign/in.latte b/app/UI/Sign/in.latte new file mode 100644 index 0000000..f6f99d0 --- /dev/null +++ b/app/UI/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/UI/Sign/out.latte b/app/UI/Sign/out.latte new file mode 100644 index 0000000..3607ad3 --- /dev/null +++ b/app/UI/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/UI/Sign/up.latte b/app/UI/Sign/up.latte new file mode 100644 index 0000000..db6e7e4 --- /dev/null +++ b/app/UI/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/UI/form-bootstrap5.latte b/app/UI/form-bootstrap5.latte new file mode 100644 index 0000000..d68125f --- /dev/null +++ b/app/UI/form-bootstrap5.latte @@ -0,0 +1,64 @@ +{* 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, textarea, datetime, file]} + {input $control class => form-control} + + {elseif $control->getOption(type) === select} + {input $control class => form-select} + + {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/create-user.php b/bin/create-user.php new file mode 100644 index 0000000..0772cd5 --- /dev/null +++ b/bin/create-user.php @@ -0,0 +1,31 @@ +createContainer(); + +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 new file mode 100644 index 0000000..1ef4482 --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "nette-examples/user-authentication", + "type": "project", + "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", + "nette/bootstrap": "^3.2", + "nette/caching": "^3.2", + "nette/database": "^3.2", + "nette/di": "^3.2", + "nette/forms": "^3.2", + "nette/http": "^3.3", + "nette/security": "^3.2", + "nette/utils": "^4.0", + "latte/latte": "^3.0", + "tracy/tracy": "^2.10" + }, + "require-dev": { + "nette/tester": "^2.5" + }, + "autoload": { + "psr-4": { + "App\\": "app" + } + }, + "minimum-stability": "stable" +} diff --git a/config/common.neon b/config/common.neon new file mode 100644 index 0000000..df776c4 --- /dev/null +++ b/config/common.neon @@ -0,0 +1,15 @@ +# Application parameters and settings. See https://doc.nette.org/configuring + +parameters: + + +application: + # Presenter mapping pattern + mapping: App\UI\*\**Presenter + + +database: + # SQLite database source location + dsn: 'sqlite:%rootDir%/data/db.sqlite' + user: + password: diff --git a/config/services.neon b/config/services.neon new file mode 100644 index 0000000..d608f5c --- /dev/null +++ b/config/services.neon @@ -0,0 +1,11 @@ +# Service registrations. See https://doc.nette.org/dependency-injection/services + +services: + - App\Core\RouterFactory::createRouter + + +search: + - in: %appDir% + classes: + - *Factory + - *Facade 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/log/.gitignore b/log/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/log/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8292db1 --- /dev/null +++ b/readme.md @@ -0,0 +1,47 @@ +User Authentication (Nette example) +=================================== + +Example of user management. + +- 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 +------------ + +```shell +git clone https://github.com/nette-examples/user-authentication +cd user-authentication +composer install +``` + +Make directories `data/`, `temp/` and `log/` writable. + +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: + +```neon +database: + dsn: 'mysql:host=127.0.0.1;dbname=***' + user: *** + password: *** +``` + +And then create the `users` table using SQL statements in the [data/mysql.sql](data/mysql.sql) file. + +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 +``` + +Then visit `http://localhost:8000` in your browser to see the welcome page. + +It requires PHP version 8.1 or newer. diff --git a/temp/.gitignore b/temp/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/temp/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/www/.htaccess b/www/.htaccess new file mode 100644 index 0000000..fb50df4 --- /dev/null +++ b/www/.htaccess @@ -0,0 +1,41 @@ +# Apache configuration file (see https://httpd.apache.org/docs/current/mod/quickreference.html) + +# Allow access to all resources by default +Require all granted + +# Disable directory listing for security reasons + + Options -Indexes + + +# Enable pretty URLs (removing the need for "index.php" in the URL) + + RewriteEngine On + + # Uncomment the next line if you want to set the base URL for rewrites + # RewriteBase / + + # Force usage of HTTPS (secure connection). Uncomment if you have SSL setup. + # RewriteCond %{HTTPS} !on + # RewriteRule .? https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + + # Permit requests to the '.well-known' directory (used for SSL verification and more) + RewriteRule ^\.well-known/.* - [L] + + # Block access to hidden files (starting with a dot) and URLs resembling WordPress admin paths + RewriteRule /\.|^\.|^wp- - [F] + + # Return 404 for missing files with specific extensions (images, scripts, styles, archives) + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule \.(pdf|js|mjs|ico|gif|jpg|jpeg|png|webp|avif|svg|css|rar|zip|7z|tar\.gz|map|eot|ttf|otf|woff|woff2)$ - [L] + + # Front controller pattern - all requests are routed through index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . index.php [L] + + +# Enable gzip compression for text files + + AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css application/javascript application/json application/xml application/rss+xml image/svg+xml + diff --git a/www/favicon.ico b/www/favicon.ico new file mode 100644 index 0000000..b20cfd0 Binary files /dev/null and b/www/favicon.ico differ diff --git a/www/index.php b/www/index.php new file mode 100644 index 0000000..55c17b1 --- /dev/null +++ b/www/index.php @@ -0,0 +1,18 @@ +createContainer(); + +// Start the application and handle the incoming request +$application = $container->getByType(Nette\Application\Application::class); +$application->run(); diff --git a/www/robots.txt b/www/robots.txt new file mode 100644 index 0000000..e69de29