├── www ├── robots.txt ├── favicon.ico ├── index.php └── .htaccess ├── .htaccess ├── log └── .gitignore ├── temp └── .gitignore ├── .gitignore ├── data ├── db.sqlite └── mysql.sql ├── app ├── Presentation │ ├── Sign │ │ ├── out.latte │ │ ├── in.latte │ │ ├── up.latte │ │ └── SignPresenter.php │ ├── Dashboard │ │ ├── default.latte │ │ └── DashboardPresenter.php │ ├── Accessory │ │ ├── FormFactory.php │ │ └── RequireLoggedUser.php │ ├── @layout.latte │ └── form-bootstrap5.latte ├── Core │ └── RouterFactory.php ├── Bootstrap.php └── Model │ └── UserFacade.php ├── config ├── services.neon └── common.neon ├── bin └── create-user.php ├── composer.json └── readme.md /www/robots.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | Require all denied 2 | -------------------------------------------------------------------------------- /log/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /temp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /data/db.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nette-examples/user-authentication/HEAD/data/db.sqlite -------------------------------------------------------------------------------- /www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nette-examples/user-authentication/HEAD/www/favicon.ico -------------------------------------------------------------------------------- /app/Presentation/Sign/out.latte: -------------------------------------------------------------------------------- 1 | {* The sign-out page *} 2 | 3 | {block content} 4 |

You have been signed out

5 | 6 |

Sign in to another account

7 | -------------------------------------------------------------------------------- /app/Presentation/Dashboard/default.latte: -------------------------------------------------------------------------------- 1 | {block content} 2 |

Dashboard

3 | 4 |

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

5 | 6 |

(Sign out)

7 | -------------------------------------------------------------------------------- /app/Presentation/Sign/in.latte: -------------------------------------------------------------------------------- 1 | {* The sign-in page *} 2 | 3 | {block content} 4 |

Sign In

5 | 6 | {include bootstrap-form signInForm} 7 | 8 |

Don't have an account yet? Sign up.

9 | -------------------------------------------------------------------------------- /app/Presentation/Sign/up.latte: -------------------------------------------------------------------------------- 1 | {* The sign-up page *} 2 | 3 | {block content} 4 |

Sign Up

5 | 6 | {include bootstrap-form signUpForm} 7 | 8 |

Already have an account? Log in.

9 | -------------------------------------------------------------------------------- /config/services.neon: -------------------------------------------------------------------------------- 1 | # Service registrations. See https://doc.nette.org/dependency-injection/services 2 | 3 | services: 4 | - App\Core\RouterFactory::createRouter 5 | 6 | 7 | search: 8 | - in: %appDir% 9 | classes: 10 | - *Facade 11 | - *Factory 12 | - *Repository 13 | - *Service 14 | -------------------------------------------------------------------------------- /data/mysql.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `users` ( 2 | `id` int(11) NOT NULL AUTO_INCREMENT, 3 | `username` varchar(100) NOT NULL, 4 | `password` varchar(100) NOT NULL, 5 | `email` varchar(100) NOT NULL, 6 | `role` varchar(100), 7 | PRIMARY KEY (`id`), 8 | UNIQUE KEY `username` (`username`) 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 10 | -------------------------------------------------------------------------------- /config/common.neon: -------------------------------------------------------------------------------- 1 | # Application parameters and settings. See https://doc.nette.org/configuring 2 | 3 | parameters: 4 | 5 | 6 | application: 7 | # Presenter mapping pattern 8 | mapping: App\Presentation\*\**Presenter 9 | 10 | 11 | database: 12 | # SQLite database source location 13 | dsn: 'sqlite:%rootDir%/data/db.sqlite' 14 | user: 15 | password: 16 | -------------------------------------------------------------------------------- /app/Presentation/Dashboard/DashboardPresenter.php: -------------------------------------------------------------------------------- 1 | bootWebApplication(); 15 | 16 | // Start the application and handle the incoming request 17 | $application = $container->getByType(Nette\Application\Application::class); 18 | $application->run(); 19 | -------------------------------------------------------------------------------- /app/Core/RouterFactory.php: -------------------------------------------------------------------------------- 1 | addRoute('/', 'Dashboard:default'); 23 | return $router; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /bin/create-user.php: -------------------------------------------------------------------------------- 1 | createContainer(); 10 | 11 | if (!isset($argv[3])) { 12 | echo ' 13 | Add new user to database. 14 | 15 | Usage: create-user.php 16 | '; 17 | exit(1); 18 | } 19 | 20 | [, $name, $email, $password] = $argv; 21 | 22 | $manager = $container->getByType(App\Model\UserFacade::class); 23 | 24 | try { 25 | $manager->add($name, $email, $password); 26 | echo "User $name was added.\n"; 27 | 28 | } catch (App\Model\DuplicateNameException $e) { 29 | echo "Error: duplicate name.\n"; 30 | exit(1); 31 | } 32 | -------------------------------------------------------------------------------- /app/Presentation/Accessory/FormFactory.php: -------------------------------------------------------------------------------- 1 | user->isLoggedIn()) { 30 | $form->addProtection(); 31 | } 32 | return $form; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nette-examples/user-authentication", 3 | "type": "project", 4 | "license": "BSD-3-Clause", 5 | "authors": [ 6 | { 7 | "name": "David Grudl", 8 | "homepage": "https://davidgrudl.com" 9 | }, 10 | { 11 | "name": "Nette Community", 12 | "homepage": "https://nette.org/contributors" 13 | } 14 | ], 15 | "require": { 16 | "php": ">= 8.1", 17 | "nette/application": "^3.2.3", 18 | "nette/bootstrap": "^3.2", 19 | "nette/caching": "^3.2", 20 | "nette/database": "^3.2", 21 | "nette/di": "^3.2", 22 | "nette/forms": "^3.2", 23 | "nette/http": "^3.3", 24 | "nette/security": "^3.2", 25 | "nette/utils": "^4.0", 26 | "latte/latte": "^3.0", 27 | "tracy/tracy": "^2.10" 28 | }, 29 | "require-dev": { 30 | "nette/tester": "^2.5" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "App\\": "app" 35 | } 36 | }, 37 | "minimum-stability": "stable" 38 | } 39 | -------------------------------------------------------------------------------- /app/Presentation/Accessory/RequireLoggedUser.php: -------------------------------------------------------------------------------- 1 | onStartup[] = function () { 21 | $user = $this->getUser(); 22 | // If the user isn't logged in, redirect them to the sign-in page 23 | if ($user->isLoggedIn()) { 24 | return; 25 | } elseif ($user->getLogoutReason() === $user::LogoutInactivity) { 26 | $this->flashMessage('You have been signed out due to inactivity. Please sign in again.'); 27 | $this->redirect('Sign:in', ['backlink' => $this->storeRequest()]); 28 | } else { 29 | $this->redirect('Sign:in'); 30 | } 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/Presentation/@layout.latte: -------------------------------------------------------------------------------- 1 | {import 'form-bootstrap5.latte'} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {* Page title with optional prefix from the child template *} 10 | {ifset title}{include title|stripHtml} | {/ifset}User Login Example 11 | 12 | {* Link to the Bootstrap stylesheet for styling *} 13 | 14 | 15 | 16 | 17 |
18 | {* Flash messages display block *} 19 |
{$flash->message}
20 | 21 | {* Main content of the child template goes here *} 22 | {include content} 23 |
24 | 25 | {* Scripts block; by default includes Nette Forms script for validation *} 26 | {block scripts} 27 | 28 | {/block} 29 | 30 | 31 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | User Authentication (Nette example) 2 | =================================== 3 | 4 | Example of user management. 5 | 6 | - User login, registration and logout (`SignPresenter`) 7 | - Command line registration (`bin/create-user.php`) 8 | - Authentication using database table (`UserFacade`) 9 | - Password hashing 10 | - Presenter requiring authentication (`DashboardPresenter`) using the `RequireLoggedUser` trait 11 | - Rendering forms using Bootstrap CSS framework 12 | - Automatic CSRF protection using a token when the user is logged in (`FormFactory`) 13 | - Separation of form factories into independent classes (`SignInFormFactory`, `SignUpFormFactory`) 14 | - Return to previous page after login (`SignPresenter::$backlink`) 15 | 16 | 17 | Installation 18 | ------------ 19 | 20 | ```shell 21 | git clone https://github.com/nette-examples/user-authentication 22 | cd user-authentication 23 | composer install 24 | ``` 25 | 26 | Make directories `data/`, `temp/` and `log/` writable. 27 | 28 | 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: 29 | 30 | ```neon 31 | database: 32 | dsn: 'mysql:host=127.0.0.1;dbname=***' 33 | user: *** 34 | password: *** 35 | ``` 36 | 37 | And then create the `users` table using SQL statements in the [data/mysql.sql](data/mysql.sql) file. 38 | 39 | The simplest way to get started is to start the built-in PHP server in the root directory of your project: 40 | 41 | ```shell 42 | php -S localhost:8000 www/index.php 43 | ``` 44 | 45 | Then visit `http://localhost:8000` in your browser to see the welcome page. 46 | 47 | It requires PHP version 8.1 or newer. 48 | -------------------------------------------------------------------------------- /www/.htaccess: -------------------------------------------------------------------------------- 1 | # Apache configuration file (see https://httpd.apache.org/docs/current/mod/quickreference.html) 2 | 3 | # Allow access to all resources by default 4 | Require all granted 5 | 6 | # Disable directory listing for security reasons 7 | 8 | Options -Indexes 9 | 10 | 11 | # Enable pretty URLs (removing the need for "index.php" in the URL) 12 | 13 | RewriteEngine On 14 | 15 | # Uncomment the next line if you want to set the base URL for rewrites 16 | # RewriteBase / 17 | 18 | # Force usage of HTTPS (secure connection). Uncomment if you have SSL setup. 19 | # RewriteCond %{HTTPS} !on 20 | # RewriteRule .? https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] 21 | 22 | # Permit requests to the '.well-known' directory (used for SSL verification and more) 23 | RewriteRule ^\.well-known/.* - [L] 24 | 25 | # Block access to hidden files (starting with a dot) and URLs resembling WordPress admin paths 26 | RewriteRule /\.|^\.|^wp- - [F] 27 | 28 | # Return 404 for missing files with specific extensions (images, scripts, styles, archives) 29 | RewriteCond %{REQUEST_FILENAME} !-f 30 | 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] 31 | 32 | # Front controller pattern - all requests are routed through index.php 33 | RewriteCond %{REQUEST_FILENAME} !-f 34 | RewriteCond %{REQUEST_FILENAME} !-d 35 | RewriteRule . index.php [L] 36 | 37 | 38 | # Enable gzip compression for text files 39 | 40 | AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css application/javascript application/json application/xml application/rss+xml image/svg+xml 41 | 42 | -------------------------------------------------------------------------------- /app/Bootstrap.php: -------------------------------------------------------------------------------- 1 | rootDir = dirname(__DIR__); 23 | 24 | // The configurator is responsible for setting up the application environment and services. 25 | // Learn more at https://doc.nette.org/en/bootstrap 26 | $this->configurator = new Configurator; 27 | 28 | // Set the directory for temporary files generated by Nette (e.g. compiled templates) 29 | $this->configurator->setTempDirectory($this->rootDir . '/temp'); 30 | } 31 | 32 | 33 | public function bootWebApplication(): Nette\DI\Container 34 | { 35 | $this->initializeEnvironment(); 36 | $this->setupContainer(); 37 | return $this->configurator->createContainer(); 38 | } 39 | 40 | 41 | public function initializeEnvironment(): void 42 | { 43 | // Nette is smart, and the development mode turns on automatically, 44 | // or you can enable for a specific IP address it by uncommenting the following line: 45 | // $this->configurator->setDebugMode('secret@23.75.345.200'); 46 | 47 | // Enables Tracy: the ultimate "swiss army knife" debugging tool. 48 | // Learn more about Tracy at https://tracy.nette.org 49 | $this->configurator->enableTracy($this->rootDir . '/log'); 50 | } 51 | 52 | 53 | private function setupContainer(): void 54 | { 55 | // Load configuration files 56 | $configDir = $this->rootDir . '/config'; 57 | $this->configurator->addConfig($configDir . '/common.neon'); 58 | $this->configurator->addConfig($configDir . '/services.neon'); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/Presentation/form-bootstrap5.latte: -------------------------------------------------------------------------------- 1 | {* Generic form template for Bootstrap v5 *} 2 | 3 | {define bootstrap-form, $name} 4 |
5 | {* List for form-level error messages *} 6 |
    7 |
  • {$error}
  • 8 |
9 | 10 | {include controls $form->getControls()} 11 |
12 | {/define} 13 | 14 | 15 | {define local controls, array $controls} 16 | {* Loop over form controls and render each one *} 17 |
20 | 21 | {* Label for the control *} 22 |
{label $control /}
23 | 24 |
25 | {include control $control} 26 | {if $control->getOption(type) === button} 27 | {while $iterator->nextValue?->getOption(type) === button} 28 | {input $iterator->nextValue class => "btn btn-secondary"} 29 | {do $iterator->next()} 30 | {/while} 31 | {/if} 32 | 33 | {* Display control-level errors or descriptions, if present *} 34 | {$control->error} 35 | {$control->getOption(description)} 36 |
37 |
38 | {/define} 39 | 40 | 41 | {define local control, Nette\Forms\Controls\BaseControl $control} 42 | {* Conditionally render controls based on their type with appropriate Bootstrap classes *} 43 | {if $control->getOption(type) in [text, select, textarea, datetime, file]} 44 | {input $control class => form-control} 45 | 46 | {elseif $control->getOption(type) === button} 47 | {input $control class => "btn btn-primary"} 48 | 49 | {elseif $control->getOption(type) in [checkbox, radio]} 50 | {var $items = $control instanceof Nette\Forms\Controls\Checkbox ? [''] : $control->getItems()} 51 |
52 | {input $control:$key class => form-check-input}{label $control:$key class => form-check-label /} 53 |
54 | 55 | {elseif $control->getOption(type) === color} 56 | {input $control class => "form-control form-control-color"} 57 | 58 | {else} 59 | {input $control} 60 | {/if} 61 | {/define} 62 | -------------------------------------------------------------------------------- /app/Model/UserFacade.php: -------------------------------------------------------------------------------- 1 | database->table(self::TableName) 45 | ->where(self::ColumnName, $username) 46 | ->fetch(); 47 | 48 | // Authentication checks 49 | if (!$user) { 50 | throw new Nette\Security\AuthenticationException('The username is incorrect.', self::IdentityNotFound); 51 | 52 | } elseif (!$this->verifyPassword($user, $password)) { 53 | throw new Nette\Security\AuthenticationException('The password is incorrect.', self::InvalidCredential); 54 | } 55 | 56 | return $this->createIdentity($user); 57 | } 58 | 59 | 60 | public function verifyPassword(ActiveRow $user, string $password): bool 61 | { 62 | if (!$this->passwords->verify($password, $user[self::ColumnPasswordHash])) { 63 | return false; 64 | } 65 | 66 | if ($this->passwords->needsRehash($user[self::ColumnPasswordHash])) { 67 | $user->update([ 68 | self::ColumnPasswordHash => $this->passwords->hash($password), 69 | ]); 70 | } 71 | 72 | return true; 73 | } 74 | 75 | 76 | public function createIdentity(ActiveRow $user): Nette\Security\IIdentity 77 | { 78 | // Return user identity without the password hash 79 | $arr = $user->toArray(); 80 | unset($arr[self::ColumnPasswordHash]); 81 | return new Nette\Security\SimpleIdentity($user[self::ColumnId], $user[self::ColumnRole], $arr); 82 | } 83 | 84 | 85 | /** 86 | * Add a new user to the database. 87 | * Throws a DuplicateNameException if the username is already taken. 88 | */ 89 | public function add(string $username, string $email, string $password): ActiveRow 90 | { 91 | // Validate the email format 92 | Nette\Utils\Validators::assert($email, 'email'); 93 | 94 | // Attempt to insert the new user into the database 95 | try { 96 | return $this->database->table(self::TableName)->insert([ 97 | self::ColumnName => $username, 98 | self::ColumnPasswordHash => $this->passwords->hash($password), 99 | self::ColumnEmail => $email, 100 | ]); 101 | } catch (Nette\Database\UniqueConstraintViolationException $e) { 102 | throw new DuplicateNameException; 103 | } 104 | } 105 | } 106 | 107 | 108 | /** 109 | * Custom exception for duplicate usernames. 110 | */ 111 | class DuplicateNameException extends \Exception 112 | { 113 | } 114 | -------------------------------------------------------------------------------- /app/Presentation/Sign/SignPresenter.php: -------------------------------------------------------------------------------- 1 | formFactory->create(); 42 | $form->addText('username', 'Username:') 43 | ->setRequired('Please enter your username.'); 44 | 45 | $form->addPassword('password', 'Password:') 46 | ->setRequired('Please enter your password.') 47 | ->setHtmlAttribute('autocomplete', 'current-password'); 48 | 49 | $form->addSubmit('send', 'Sign in'); 50 | 51 | // Handle form submission 52 | $form->onSuccess[] = function (Form $form, \stdClass $data): void { 53 | try { 54 | // Attempt to login user 55 | $this->getUser()->login($data->username, $data->password); 56 | $this->restoreRequest($this->backlink); 57 | $this->redirect('Dashboard:'); 58 | } catch (Nette\Security\AuthenticationException) { 59 | $form->addError('The username or password you entered is incorrect.'); 60 | } 61 | }; 62 | 63 | return $form; 64 | } 65 | 66 | 67 | /** 68 | * Create a sign-up form with fields for username, email, and password. 69 | * On successful submission, the user is redirected to the dashboard. 70 | */ 71 | protected function createComponentSignUpForm(): Form 72 | { 73 | $form = $this->formFactory->create(); 74 | $form->addText('username', 'Pick a username:') 75 | ->setRequired('Please pick a username.'); 76 | 77 | $form->addEmail('email', 'Your e-mail:') 78 | ->setRequired('Please enter your e-mail.'); 79 | 80 | $form->addPassword('password', 'Create a password:') 81 | ->setOption('description', sprintf('at least %d characters', $this->userFacade::PasswordMinLength)) 82 | ->setRequired('Please create a password.') 83 | ->addRule($form::MinLength, null, $this->userFacade::PasswordMinLength) 84 | ->setHtmlAttribute('autocomplete', 'new-password'); 85 | 86 | $form->addSubmit('send', 'Sign up'); 87 | 88 | // Handle form submission 89 | $form->onSuccess[] = function (Form $form, \stdClass $data): void { 90 | try { 91 | // Attempt to register a new user 92 | $this->userFacade->add($data->username, $data->email, $data->password); 93 | $this->redirect('Dashboard:'); 94 | } catch (DuplicateNameException) { 95 | // Handle the case where the username is already taken 96 | $form['username']->addError('Username is already taken.'); 97 | } 98 | }; 99 | 100 | return $form; 101 | } 102 | 103 | 104 | /** 105 | * Logs out the currently authenticated user. 106 | */ 107 | public function actionOut(): void 108 | { 109 | $this->getUser()->logout(); 110 | } 111 | } 112 | --------------------------------------------------------------------------------