diff --git a/README.md b/README.md index 5b5816dc..b02b0ac7 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ A household needs to be managed. I did this so far (almost 10 years) with my fir > > See https://github.com/berrnd/grocy-desktop or directly download the [latest release](https://releases.grocy.info/latest-desktop) - the installation is nothing more than just clicking 2 times "next"... -Just unpack the [latest release](https://releases.grocy.info/latest) on your PHP (SQLite extension required, currently only tested with PHP 7.2) enabled webserver (webservers root should point to the `/public` directory), copy `config-dist.php` to `data/config.php`, edit it to your needs, ensure that the `data` directory is writable and you're ready to go. +Just unpack the [latest release](https://releases.grocy.info/latest) on your PHP (SQLite extension required, currently only tested with PHP 7.2) enabled webserver (webservers root should point to the `/public` directory), copy `config-dist.php` to `data/config.php`, edit it to your needs, ensure that the `data` directory is writable and you're ready to go. Default login is user `admin` with password `admin`, please change the password immediately (see user menu). -Default login is user `admin` with password `admin` - see the `data/config.php` file. Alternatively clone this repository and install Composer and Yarn dependencies manually. +Alternatively clone this repository and install Composer and Yarn dependencies manually. If you use nginx as your webserver, please include `try_files $uri /index.php;` in your location block. diff --git a/config-dist.php b/config-dist.php index e79f867f..bdfef11d 100644 --- a/config-dist.php +++ b/config-dist.php @@ -1,9 +1,5 @@ getParsedBody(); if (isset($postParams['username']) && isset($postParams['password'])) { - if ($postParams['username'] === HTTP_USER && $postParams['password'] === HTTP_PASSWORD) + $user = $this->Database->users()->where('username', $postParams['username'])->fetch(); + $inputPassword = $postParams['password']; + + if ($user !== null && password_verify($inputPassword, $user->password)) { - $sessionKey = $this->SessionService->CreateSession(); + $sessionKey = $this->SessionService->CreateSession($user->id); setcookie($this->SessionCookieName, $sessionKey, time() + 31536000); // Cookie expires in 1 year, but session validity is up to SessionService + define('GROCY_USER_USERNAME', $user->username); + define('GROCY_USER_ID', $user->id); + + if (password_needs_rehash($user->password, PASSWORD_DEFAULT)) + { + $user->update(array( + 'password' => password_hash($inputPassword, PASSWORD_DEFAULT) + )); + } return $response->withRedirect($this->AppContainer->UrlManager->ConstructUrl('/')); } @@ -69,6 +81,30 @@ class LoginController extends BaseController return $response->withRedirect($this->AppContainer->UrlManager->ConstructUrl('/stockoverview')); } + public function UsersList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + return $this->AppContainer->view->render($response, 'users', [ + 'users' => $this->Database->users()->orderBy('username') + ]); + } + + public function UserEditForm(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + if ($args['userId'] == 'new') + { + return $this->AppContainer->view->render($response, 'userform', [ + 'mode' => 'create' + ]); + } + else + { + return $this->AppContainer->view->render($response, 'userform', [ + 'user' => $this->Database->users($args['userId']), + 'mode' => 'edit' + ]); + } + } + public function GetSessionCookieName() { return $this->SessionCookieName; diff --git a/controllers/UsersApiController.php b/controllers/UsersApiController.php new file mode 100644 index 00000000..c2eb33fc --- /dev/null +++ b/controllers/UsersApiController.php @@ -0,0 +1,59 @@ +UsersService = new UsersService(); + } + + protected $UsersService; + + public function CreateUser(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + $requestBody = $request->getParsedBody(); + + try + { + $this->UsersService->CreateUser($requestBody['username'], $requestBody['first_name'], $requestBody['last_name'], $requestBody['password']); + return $this->ApiResponse(array('success' => $success)); + } + catch (\Exception $ex) + { + return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage()); + } + } + + public function DeleteUser(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + try + { + $this->UsersService->DeleteUser($args['userId']); + return $this->ApiResponse(array('success' => $success)); + } + catch (\Exception $ex) + { + return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage()); + } + } + + public function EditUser(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + $requestBody = $request->getParsedBody(); + + try + { + $this->UsersService->EditUser($args['userId'], $requestBody['username'], $requestBody['first_name'], $requestBody['last_name'], $requestBody['password']); + return $this->ApiResponse(array('success' => $success)); + } + catch (\Exception $ex) + { + return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage()); + } + } +} diff --git a/grocy.openapi.json b/grocy.openapi.json index 43c12d3e..841a570a 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -370,6 +370,140 @@ } } }, + "/users/create": { + "post": { + "description": "Creates a new user", + "tags": [ + "User management" + ], + "requestBody": { + "description": "A valid user object", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + }, + "400": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse" + } + } + } + } + } + } + }, + "/users/edit/{userId}": { + "post": { + "description": "Edits the given user", + "tags": [ + "User management" + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "description": "A valid user id", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "description": "A valid user object", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + }, + "400": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse" + } + } + } + } + } + } + }, + "/users/delete/{userId}": { + "get": { + "description": "Deletes the given user", + "tags": [ + "User management" + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "description": "A valid user id", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoidApiActionResponse" + } + } + } + }, + "400": { + "description": "A VoidApiActionResponse object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse" + } + } + } + } + } + } + }, "/stock/add-product/{productId}/{amount}": { "get": { "description": "Adds the the given amount of the given product to stock", @@ -1182,6 +1316,30 @@ } } }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "row_created_timestamp": { + "type": "string", + "format": "date-time" + } + } + }, "ApiKey": { "type": "object", "properties": { diff --git a/localization/de.php b/localization/de.php index d4279b5b..88a98222 100644 --- a/localization/de.php +++ b/localization/de.php @@ -169,6 +169,17 @@ return array( 'Put missing amount on shopping list' => 'Fehlende Menge auf den Einkaufszettel setzen', 'Are you sure to put all missing ingredients for recipe "#1" on the shopping list?' => 'Sicher alle fehlenden Zutaten für Rezept "#1" auf die Einkaufsliste zu setzen?', 'Added for recipe #1' => 'Hinzugefügt für Rezept #1', + 'Manage users' => 'Benutzer verwalten', + 'Users' => 'Benutzer', + 'Are you sure to delete user "#1"?' => 'Benutzer "#1" wirklich löschen?', + 'Create user' => 'Benutzer erstellen', + 'Edit user' => 'Benutzer bearbeiten', + 'First name' => 'Vorname', + 'Last name' => 'Nachname', + 'A username is required' => 'Ein Benutzername ist erforderlich', + 'Confirm password' => 'Passwort bestätigen', + 'Passwords do not match' => 'Passwörter stimmen nicht überein', + 'Change password' => 'Passwort ändern', //Constants 'manually' => 'Manuell', @@ -226,5 +237,6 @@ return array( 'German' => 'Deutsch', 'Italian' => 'Italienisch', 'Demo in different language' => 'Demo in anderer Sprache', - 'This is the note content of the recipe ingredient' => 'Dies ist der Inhalt der Notiz der Zutat' + 'This is the note content of the recipe ingredient' => 'Dies ist der Inhalt der Notiz der Zutat', + 'Demo User' => 'Demo Benutzer' ); diff --git a/middleware/SessionAuthMiddleware.php b/middleware/SessionAuthMiddleware.php index 0037b3e3..e10b6a0f 100644 --- a/middleware/SessionAuthMiddleware.php +++ b/middleware/SessionAuthMiddleware.php @@ -3,6 +3,7 @@ namespace Grocy\Middleware; use \Grocy\Services\SessionService; +use \Grocy\Services\LocalizationService; class SessionAuthMiddleware extends BaseMiddleware { @@ -21,7 +22,15 @@ class SessionAuthMiddleware extends BaseMiddleware if ($routeName === 'root' || $this->ApplicationService->IsDemoInstallation() || $this->ApplicationService->IsEmbeddedInstallation()) { - define('AUTHENTICATED', $this->ApplicationService->IsDemoInstallation() || $this->ApplicationService->IsEmbeddedInstallation()); + if ($this->ApplicationService->IsDemoInstallation() || $this->ApplicationService->IsEmbeddedInstallation()) + { + define('AUTHENTICATED', true); + + $localizationService = new LocalizationService(CULTURE); + define('GROCY_USER_USERNAME', $localizationService->Localize('Demo User')); + define('GROCY_USER_ID', -1); + } + $response = $next($request, $response); } else @@ -34,7 +43,18 @@ class SessionAuthMiddleware extends BaseMiddleware } else { - define('AUTHENTICATED', $routeName !== 'login'); + if ($routeName !== 'login') + { + $user = $sessionService->GetUserBySessionKey($_COOKIE[$this->SessionCookieName]); + define('AUTHENTICATED', true); + define('GROCY_USER_USERNAME', $user->username); + define('GROCY_USER_ID', $user->id); + } + else + { + define('AUTHENTICATED', false); + } + $response = $next($request, $response); } } diff --git a/migrations/0026.sql b/migrations/0026.sql new file mode 100644 index 00000000..68c2f167 --- /dev/null +++ b/migrations/0026.sql @@ -0,0 +1,20 @@ +CREATE TABLE users ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + username TEXT NOT NULL UNIQUE, + first_name TEXT, + last_name TEXT, + password TEXT NOT NULL, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) +); + +DROP TABLE sessions; + +CREATE TABLE sessions ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + session_key TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL, + expires DATETIME, + last_used DATETIME, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) +) + diff --git a/migrations/0027.php b/migrations/0027.php new file mode 100644 index 00000000..4474adbc --- /dev/null +++ b/migrations/0027.php @@ -0,0 +1,24 @@ +DatabaseService->GetDbConnection(); + +if (defined('HTTP_USER')) +{ + // Migrate old user defined in config file to database + $newUserRow = $db->users()->createRow(array( + 'username' => HTTP_USER, + 'password' => password_hash(HTTP_PASSWORD, PASSWORD_DEFAULT) + )); + $newUserRow->save(); +} +else +{ + // Create default user "admin" with password "admin" + $newUserRow = $db->users()->createRow(array( + 'username' => 'admin', + 'password' => password_hash('admin', PASSWORD_DEFAULT) + )); + $newUserRow->save(); +} diff --git a/public/viewjs/userform.js b/public/viewjs/userform.js new file mode 100644 index 00000000..b7b51917 --- /dev/null +++ b/public/viewjs/userform.js @@ -0,0 +1,73 @@ +$('#save-user-button').on('click', function(e) +{ + e.preventDefault(); + + if (Grocy.EditMode === 'create') + { + Grocy.Api.Post('users/create', $('#user-form').serializeJSON(), + function(result) + { + window.location.href = U('/users'); + }, + function(xhr) + { + console.error(xhr); + } + ); + } + else + { + Grocy.Api.Post('users/edit/' + Grocy.EditObjectId, $('#user-form').serializeJSON(), + function(result) + { + window.location.href = U('/users'); + }, + function(xhr) + { + console.error(xhr); + } + ); + } +}); + +$('#user-form input').keyup(function (event) +{ + var element = document.getElementById("password_confirm"); + if ($("#password").val() !== $("#password_confirm").val()) + { + element.setCustomValidity("error"); + } + else + { + element.setCustomValidity(""); + } + + Grocy.FrontendHelpers.ValidateForm('user-form'); +}); + +$('#user-form input').keydown(function (event) +{ + if (event.keyCode === 13) //Enter + { + if (document.getElementById('user-form').checkValidity() === false) //There is at least one validation error + { + event.preventDefault(); + return false; + } + else + { + $('#save-user-button').click(); + } + } +}); + +if (GetUriParam("changepw") === "true") +{ + $('#password').focus(); +} +else +{ + $('#username').focus(); +} + +Grocy.FrontendHelpers.ValidateForm('user-form'); diff --git a/public/viewjs/users.js b/public/viewjs/users.js new file mode 100644 index 00000000..9afbdedc --- /dev/null +++ b/public/viewjs/users.js @@ -0,0 +1,58 @@ +var usersTable = $('#users-table').DataTable({ + 'paginate': false, + 'order': [[1, 'asc']], + 'columnDefs': [ + { 'orderable': false, 'targets': 0 } + ], + 'language': JSON.parse(L('datatables_localization')), + 'scrollY': false, + 'colReorder': true, + 'stateSave': true +}); + +$("#search").on("keyup", function() +{ + var value = $(this).val(); + if (value === "all") + { + value = ""; + } + + usersTable.search(value).draw(); +}); + +$(document).on('click', '.user-delete-button', function (e) +{ + var objectName = $(e.currentTarget).attr('data-user-username'); + var objectId = $(e.currentTarget).attr('data-user-id'); + + bootbox.confirm({ + message: L('Are you sure to delete user "#1"?', objectName), + buttons: { + confirm: { + label: L('Yes'), + className: 'btn-success' + }, + cancel: { + label: L('No'), + className: 'btn-danger' + } + }, + callback: function(result) + { + if (result === true) + { + Grocy.Api.Get('users/delete' + objectId, + function(result) + { + window.location.href = U('/users'); + }, + function(xhr) + { + console.error(xhr); + } + ); + } + } + }); +}); diff --git a/routes.php b/routes.php index 659bbebb..daa628cf 100644 --- a/routes.php +++ b/routes.php @@ -11,10 +11,12 @@ $app->group('', function() // Base route $this->get('/', 'LoginControllerInstance:Root')->setName('root'); - // Login routes + // Login/user routes $this->get('/login', 'LoginControllerInstance:LoginPage')->setName('login'); $this->post('/login', 'LoginControllerInstance:ProcessLogin')->setName('login'); $this->get('/logout', 'LoginControllerInstance:Logout'); + $this->get('/users', 'LoginControllerInstance:UsersList'); + $this->get('/user/{userId}', 'LoginControllerInstance:UserEditForm'); // Stock routes $this->get('/stockoverview', 'Grocy\Controllers\StockController:Overview'); @@ -69,6 +71,10 @@ $app->group('/api', function() $this->post('/edit-object/{entity}/{objectId}', 'Grocy\Controllers\GenericEntityApiController:EditObject'); $this->get('/delete-object/{entity}/{objectId}', 'Grocy\Controllers\GenericEntityApiController:DeleteObject'); + $this->post('/users/create', 'Grocy\Controllers\UsersApiController:CreateUser'); + $this->post('/users/edit/{userId}', 'Grocy\Controllers\UsersApiController:EditUser'); + $this->get('/users/delete/{userId}', 'Grocy\Controllers\UsersApiController:DeleteUser'); + $this->get('/stock/add-product/{productId}/{amount}', 'Grocy\Controllers\StockApiController:AddProduct'); $this->get('/stock/consume-product/{productId}/{amount}', 'Grocy\Controllers\StockApiController:ConsumeProduct'); $this->get('/stock/inventory-product/{productId}/{newAmount}', 'Grocy\Controllers\StockApiController:InventoryProduct'); diff --git a/services/DatabaseMigrationService.php b/services/DatabaseMigrationService.php index 7e3f2210..77ecabde 100644 --- a/services/DatabaseMigrationService.php +++ b/services/DatabaseMigrationService.php @@ -8,21 +8,38 @@ class DatabaseMigrationService extends BaseService { $this->DatabaseService->ExecuteDbStatement("CREATE TABLE IF NOT EXISTS migrations (migration INTEGER NOT NULL PRIMARY KEY UNIQUE, execution_time_timestamp DATETIME DEFAULT (datetime('now', 'localtime')))"); - $migrationFiles = array(); + $sqlMigrationFiles = array(); foreach (new \FilesystemIterator(__DIR__ . '/../migrations') as $file) { - $migrationFiles[$file->getBasename('.sql')] = $file->getPathname(); + if ($file->getExtension() === 'sql') + { + $sqlMigrationFiles[$file->getBasename('.sql')] = $file->getPathname(); + } } - ksort($migrationFiles); - - foreach($migrationFiles as $migrationNumber => $migrationFile) + ksort($sqlMigrationFiles); + foreach($sqlMigrationFiles as $migrationNumber => $migrationFile) { $migrationNumber = ltrim($migrationNumber, '0'); - $this->ExecuteMigrationWhenNeeded($migrationNumber, file_get_contents($migrationFile)); + $this->ExecuteSqlMigrationWhenNeeded($migrationNumber, file_get_contents($migrationFile)); + } + + $phpMigrationFiles = array(); + foreach (new \FilesystemIterator(__DIR__ . '/../migrations') as $file) + { + if ($file->getExtension() === 'php') + { + $phpMigrationFiles[$file->getBasename('.php')] = $file->getPathname(); + } + } + ksort($phpMigrationFiles); + foreach($phpMigrationFiles as $migrationNumber => $migrationFile) + { + $migrationNumber = ltrim($migrationNumber, '0'); + $this->ExecutePhpMigrationWhenNeeded($migrationNumber, $migrationFile); } } - private function ExecuteMigrationWhenNeeded(int $migrationId, string $sql) + private function ExecuteSqlMigrationWhenNeeded(int $migrationId, string $sql) { $rowCount = $this->DatabaseService->ExecuteDbQuery('SELECT COUNT(*) FROM migrations WHERE migration = ' . $migrationId)->fetchColumn(); if (intval($rowCount) === 0) @@ -31,4 +48,14 @@ class DatabaseMigrationService extends BaseService $this->DatabaseService->ExecuteDbStatement('INSERT INTO migrations (migration) VALUES (' . $migrationId . ')'); } } + + private function ExecutePhpMigrationWhenNeeded(int $migrationId, string $phpFile) + { + $rowCount = $this->DatabaseService->ExecuteDbQuery('SELECT COUNT(*) FROM migrations WHERE migration = ' . $migrationId)->fetchColumn(); + if (intval($rowCount) === 0) + { + include $phpFile; + $this->DatabaseService->ExecuteDbStatement('INSERT INTO migrations (migration) VALUES (' . $migrationId . ')'); + } + } } diff --git a/services/DemoDataGeneratorService.php b/services/DemoDataGeneratorService.php index 6f26f946..2afd57d8 100644 --- a/services/DemoDataGeneratorService.php +++ b/services/DemoDataGeneratorService.php @@ -16,6 +16,8 @@ class DemoDataGeneratorService extends BaseService $loremIpsum = 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.'; $sql = " + INSERT INTO users (id, username, password) VALUES (-1, '{$localizationService->Localize('Demo User')}', 'x'); + INSERT INTO locations (name) VALUES ('{$localizationService->Localize('Pantry')}'); --2 INSERT INTO locations (name) VALUES ('{$localizationService->Localize('Candy cupboard')}'); --3 INSERT INTO locations (name) VALUES ('{$localizationService->Localize('Tinned food cupboard')}'); --4 diff --git a/services/SessionService.php b/services/SessionService.php index 44becd41..c8746d3d 100644 --- a/services/SessionService.php +++ b/services/SessionService.php @@ -33,11 +33,12 @@ class SessionService extends BaseService /** * @return string */ - public function CreateSession() + public function CreateSession($userId) { $newSessionKey = $this->GenerateSessionKey(); $sessionRow = $this->Database->sessions()->createRow(array( + 'user_id' => $userId, 'session_key' => $newSessionKey, 'expires' => date('Y-m-d H:i:s', time() + 2592000) // Default is that sessions expire in 30 days )); @@ -51,6 +52,16 @@ class SessionService extends BaseService $this->Database->sessions()->where('session_key', $sessionKey)->delete(); } + public function GetUserBySessionKey($sessionKey) + { + $sessionRow = $this->Database->sessions()->where('session_key', $sessionKey)->fetch(); + if ($sessionRow !== null) + { + return $this->Database->users($sessionRow->user_id); + } + return null; + } + private function GenerateSessionKey() { return RandomString(50); diff --git a/services/UsersService.php b/services/UsersService.php new file mode 100644 index 00000000..cced4730 --- /dev/null +++ b/services/UsersService.php @@ -0,0 +1,47 @@ +Database->users()->createRow(array( + 'username' => $username, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'password' => password_hash($password, PASSWORD_DEFAULT) + )); + $newUserRow->save(); + } + + public function EditUser(int $userId, string $username, string $firstName, string $lastName, string $password) + { + if (!$this->UserExists($userId)) + { + throw new \Exception('User does not exist'); + } + + $user = $this->Database->users($userId); + $user->update(array( + 'username' => $username, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'password' => password_hash($password, PASSWORD_DEFAULT) + )); + } + + public function DeleteUser($userId) + { + $row = $this->Database->users($args['userId']); + $row->delete(); + $success = $row->isClean(); + return $this->ApiResponse(array('success' => $success)); + } + + private function UserExists($userId) + { + $userRow = $this->Database->users()->where('id = :1', $userId)->fetch(); + return $userRow !== null; + } +} diff --git a/views/layout/default.blade.php b/views/layout/default.blade.php index 1172f6b8..4241b167 100644 --- a/views/layout/default.blade.php +++ b/views/layout/default.blade.php @@ -166,10 +166,12 @@