From b6c07a51cebb5bf87e981cb86426d8bc66b20e47 Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Wed, 26 Aug 2026 20:08:22 +0200 Subject: [PATCH] Optimized session handling --- changelog/33_1.19.0_2018-09-24.md | 2 +- changelog/49_2.4.2_2019-06-09.md | 2 +- changelog/65_3.1.3_2021-11-14.md | 2 +- changelog/82_UNRELEASED_xxxx-xx-xx.md | 3 + controllers/Api/SystemApiController.php | 18 ++- controllers/LoginController.php | 7 +- controllers/UsersController.php | 29 +++++ grocy.openapi.json | 32 +---- helpers/ConfigurationValidator.php | 43 ++++-- helpers/extensions.php | 16 +-- localization/strings.pot | 28 +++- middleware/Auth/BaseAuthMiddleware.php | 48 ++++++- middleware/Auth/DefaultAuthMiddleware.php | 12 +- middleware/Auth/LdapAuthMiddleware.php | 11 +- .../Auth/ReverseProxyAuthMiddleware.php | 2 +- middleware/Auth/SessionAuthMiddleware.php | 25 +++- migrations/0020.sql | 2 +- migrations/0026.sql | 3 +- migrations/0256.sql | 17 +++ plugins/DemoBarcodeLookupPlugin.php | 2 +- public/viewjs/managesessions.js | 67 ++++++++++ routes.php | 1 + services/ApiKeyService.php | 7 +- services/ApplicationService.php | 2 +- services/DatabaseMigrationService.php | 2 + services/SessionService.php | 105 ++++++++------- views/login.blade.php | 12 +- views/managesessions.blade.php | 122 ++++++++++++++++++ views/userpermissions.blade.php | 7 +- views/users.blade.php | 22 ++-- 30 files changed, 502 insertions(+), 149 deletions(-) create mode 100644 migrations/0256.sql create mode 100644 public/viewjs/managesessions.js create mode 100644 views/managesessions.blade.php diff --git a/changelog/33_1.19.0_2018-09-24.md b/changelog/33_1.19.0_2018-09-24.md index 21e9d1ca..66df18ba 100644 --- a/changelog/33_1.19.0_2018-09-24.md +++ b/changelog/33_1.19.0_2018-09-24.md @@ -1,6 +1,6 @@ - New feature: Tasks / To-do list - Renamed habits to chores as this is more what it is about - Products can now be organized in product groups, this group is also used to group the items on the shopping list (you can use this to optimize your way in the supermarket for example) -- Added an option to stay logged in permanently (checkbox on the login page) +- Added an option to stay logged in permanently ("Remember Me" checkbox on the login page) - When the database was changed externally, the current page is automatically reloaded when there was no input for at least 50 seconds - Fixed some minor UI bugs diff --git a/changelog/49_2.4.2_2019-06-09.md b/changelog/49_2.4.2_2019-06-09.md index c48ab482..8f7a0786 100644 --- a/changelog/49_2.4.2_2019-06-09.md +++ b/changelog/49_2.4.2_2019-06-09.md @@ -2,7 +2,7 @@ - Fixed a problem that the user settings were not properly initialized for the frontend JS part when not logged only (so potentially affected only the login page) - Fixed an issue that the shopping list did not load when a plural translation for a quantity unit was missing - Fixed that tooltips were visible forever when consuming all products on the stock overview page -- Fixed that login did not work when "Stay logged in permanently" was set and Grocy runs on a 32-bit system (thanks @matejdro) +- Fixed that login did not work when "Remember Me" was set and Grocy runs on a 32-bit system (thanks @matejdro) - Fixed page reloads when "Auto reload on external changes" is enabled and there is unsaved form data (the detection did not work for forms in modal dialogs, e. g. when adding a entry to the meal plan) - Fixed (again) that the product picker did not work properly when the product name contains single quotes - Fixed that a entered barcode on the product edit page was only saved when "adding" it to the barcodes list by pressing `TAB` (is now automatically added to the list also when just leaving the field) diff --git a/changelog/65_3.1.3_2021-11-14.md b/changelog/65_3.1.3_2021-11-14.md index 06703e5e..69dc8fdb 100644 --- a/changelog/65_3.1.3_2021-11-14.md +++ b/changelog/65_3.1.3_2021-11-14.md @@ -11,7 +11,7 @@ - Fixed that dropdown filters for tables maybe did not work after reordering columns - Fixed that auto night mode over midnight did not always work - Fixed that the labels of context-/more-menu items were not readable in Night Mode (thanks @corbolais) -- Fixed that the "Stay logged in permanently" checkbox on the login page had no effect (thanks @0) +- Fixed that the "Remember Me" checkbox on the login page had no effect (thanks @0) ### API - New endpoint `/chores/{choreIdToKeep}/merge/{choreIdToRemove}` for merging chores diff --git a/changelog/82_UNRELEASED_xxxx-xx-xx.md b/changelog/82_UNRELEASED_xxxx-xx-xx.md index a76c1de8..990e6078 100644 --- a/changelog/82_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/82_UNRELEASED_xxxx-xx-xx.md @@ -1,5 +1,7 @@ > ⚠️ Authentication middleware was reorganized, review your `AUTH_CLASS` setting (see the default reference in `config-dist.php` as usual) +> ⚠️ This update invalidates all sessions (means you will have to log in again) + ### New Feature: xxxx - xxx @@ -57,6 +59,7 @@ ### General +- It's now possible to view (and delete) active sessions of a user (new button per user on the "Manage users" page) - Fixed accent insensitive searching using the general table search field was broken - Fixed that it wasn't possible to log in using passwords containing special escape sequences (e.g. `<<`) - Fixed that the initially created location and quantity units weren't localized (only applies to new installations) diff --git a/controllers/Api/SystemApiController.php b/controllers/Api/SystemApiController.php index ef1dd0b0..66bf16b1 100644 --- a/controllers/Api/SystemApiController.php +++ b/controllers/Api/SystemApiController.php @@ -17,10 +17,24 @@ class SystemApiController extends BaseApiController $constants = get_defined_constants(); // Some GROCY_* constants are not really config settings and therefore should not be exposed - unset($constants['GROCY_AUTHENTICATED'], $constants['GROCY_DATAPATH'], $constants['GROCY_IS_EMBEDDED_INSTALL'], $constants['GROCY_USER_ID']); + unset( + $constants['GROCY_AUTHENTICATED'], + $constants['GROCY_DATAPATH'], + $constants['GROCY_IS_EMBEDDED_INSTALL'], + $constants['GROCY_REVERSE_PROXY_AUTH_HEADER'], + $constants['GROCY_REVERSE_PROXY_AUTH_USE_ENV'], + $constants['GROCY_LDAP_ADDRESS'], + $constants['GROCY_LDAP_BASE_DN'], + $constants['GROCY_LDAP_BIND_DN'], + $constants['GROCY_LDAP_BIND_PW'], + $constants['GROCY_LDAP_USER_FILTER'], + $constants['GROCY_LDAP_UID_ATTR'], + $constants['GROCY_USER_USERNAME'], + $constants['GROCY_USER_PICTURE_FILE_NAME'], + $constants['GROCY_USER_ID'] + ); $returnArray = []; - foreach ($constants as $constant => $value) { if (substr($constant, 0, 6) === 'GROCY_') diff --git a/controllers/LoginController.php b/controllers/LoginController.php index 5a1694f7..7ce75350 100644 --- a/controllers/LoginController.php +++ b/controllers/LoginController.php @@ -15,7 +15,12 @@ class LoginController extends BaseController public function Logout(Request $request, Response $response, array $args) { - SessionService::GetInstance()->RemoveSession($_COOKIE[SessionService::SESSION_COOKIE_NAME]); + SessionService::GetInstance()->DeleteToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]); + if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME])) + { + SessionService::GetInstance()->DeleteToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]); + } + return $response->withRedirect($this->AppContainer->get('UrlManager')->ConstructUrl('/')); } diff --git a/controllers/UsersController.php b/controllers/UsersController.php index 8edd8497..6cb14e5c 100644 --- a/controllers/UsersController.php +++ b/controllers/UsersController.php @@ -3,6 +3,7 @@ namespace Grocy\Controllers; use Grocy\Controllers\Users\User; +use Grocy\Services\SessionService; use Grocy\Services\UserfieldsService; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -73,4 +74,32 @@ class UsersController extends BaseController 'userfieldValues' => UserfieldsService::GetInstance()->GetAllValues('users') ]); } + + public function SessionList(Request $request, Response $response, array $args) + { + if ($args['userId'] == GROCY_USER_ID) + { + User::CheckPermission($request, User::PERMISSION_USERS_EDIT_SELF); + } + else + { + User::CheckPermission($request, User::PERMISSION_USERS_EDIT); + } + + $thisSessionTokenHashes = []; + if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS])) + { + $thisSessionTokenHashes[] = hash('sha256', $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]); + } + if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME])) + { + $thisSessionTokenHashes[] = hash('sha256', $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]); + } + + return $this->RenderPage($response, 'managesessions', [ + 'user' => $this->DB->users($args['userId']), + 'thisSessionTokenHashes' => $thisSessionTokenHashes, + 'sessionRows' => $this->DB->sessions()->where('user_id', $args['userId'])->orderBy('id') + ]); + } } diff --git a/grocy.openapi.json b/grocy.openapi.json index ef9140e3..cb95a93e 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -5189,29 +5189,6 @@ "next_estimated_charge_time": "2999-12-31 23:59:59" } }, - "Session": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "session_key": { - "type": "string" - }, - "expires": { - "type": "string", - "format": "date-time" - }, - "last_used": { - "type": "string", - "format": "date-time" - }, - "row_created_timestamp": { - "type": "string", - "format": "date-time" - } - } - }, "User": { "type": "object", "properties": { @@ -5956,13 +5933,15 @@ "battery_charge_cycles", "product_barcodes_view", "permission_hierarchy", - "uihelper_shopping_list" + "uihelper_shopping_list", + "sessions" ] }, "ExposedEntityNoListing": { "type": "string", "enum": [ - "api_keys" + "api_keys", + "sessions" ] }, "ExposedEntityNoEdit": { @@ -5980,7 +5959,8 @@ "battery_charge_cycles", "product_barcodes_view", "permission_hierarchy", - "uihelper_shopping_list" + "uihelper_shopping_list", + "sessions" ] }, "ExposedEntityNoDelete": { diff --git a/helpers/ConfigurationValidator.php b/helpers/ConfigurationValidator.php index 1a1dbf8b..55057b19 100644 --- a/helpers/ConfigurationValidator.php +++ b/helpers/ConfigurationValidator.php @@ -4,22 +4,29 @@ namespace Grocy\Helpers; class EInvalidConfig extends \Exception { + public function __construct(string $message) + { + $message .= '

----------
Check your "config.php" file (which is in your data directory: "' . realpath(GROCY_DATAPATH) . '").
Defaults can be found in "config-dist.php" (which is in your main Grocy directory: "' . realpath(__DIR__ . '/../') . '").'; + + parent::__construct($message); + } } class ConfigurationValidator { public function validateConfig() { - self::checkMode(); - self::checkDefaultLocale(); - self::checkCurrencyFormat(); - self::checkFirstDayOfWeek(); - self::checkEntryPage(); - self::checkMealplanFirstDayOfWeek(); - self::checkAutoNightModeRange(); + self::CheckMode(); + self::CheckDefaultLocale(); + self::CheckCurrencyFormat(); + self::CheckFirstDayOfWeek(); + self::CheckEntryPage(); + self::CheckMealplanFirstDayOfWeek(); + self::CheckAutoNightModeRange(); + self::CheckAuthClass(); } - private function checkMode() + private function CheckMode() { $allowedModes = ['production', 'dev', 'demo', 'prerelease']; if (!in_array(GROCY_MODE, $allowedModes)) @@ -28,7 +35,7 @@ class ConfigurationValidator } } - private function checkDefaultLocale() + private function CheckDefaultLocale() { if (!file_exists(__DIR__ . '/../localization/' . GROCY_DEFAULT_LOCALE)) { @@ -36,7 +43,7 @@ class ConfigurationValidator } } - private function checkFirstDayOfWeek() + private function CheckFirstDayOfWeek() { if (!(GROCY_CALENDAR_FIRST_DAY_OF_WEEK == '' || (is_numeric(GROCY_CALENDAR_FIRST_DAY_OF_WEEK) && GROCY_CALENDAR_FIRST_DAY_OF_WEEK >= 0 && GROCY_CALENDAR_FIRST_DAY_OF_WEEK <= 6))) @@ -45,7 +52,7 @@ class ConfigurationValidator } } - private function checkCurrencyFormat() + private function CheckCurrencyFormat() { if (!(preg_match('/^([A-z]){3}$/', GROCY_CURRENCY))) { @@ -53,7 +60,7 @@ class ConfigurationValidator } } - private function checkEntryPage() + private function CheckEntryPage() { $allowedPages = ['stock', 'shoppinglist', 'recipes', 'chores', 'tasks', 'batteries', 'equipment', 'calendar', 'mealplan']; if (!in_array(GROCY_ENTRY_PAGE, $allowedPages)) @@ -62,7 +69,7 @@ class ConfigurationValidator } } - private function checkMealplanFirstDayOfWeek() + private function CheckMealplanFirstDayOfWeek() { if (!(GROCY_MEAL_PLAN_FIRST_DAY_OF_WEEK == '' || (is_numeric(GROCY_MEAL_PLAN_FIRST_DAY_OF_WEEK) && GROCY_MEAL_PLAN_FIRST_DAY_OF_WEEK >= -1 && GROCY_MEAL_PLAN_FIRST_DAY_OF_WEEK <= 6))) @@ -71,7 +78,7 @@ class ConfigurationValidator } } - private function checkAutoNightModeRange() + private function CheckAutoNightModeRange() { global $GROCY_DEFAULT_USER_SETTINGS; if (!(preg_match('/^(?:2[0-3]|[01][0-9]):[0-5][0-9]$/', $GROCY_DEFAULT_USER_SETTINGS['auto_night_mode_time_range_from']))) @@ -83,4 +90,12 @@ class ConfigurationValidator throw new EInvalidConfig('auto_night_mode_time_range_to is not in HH:mm format (' . $GROCY_DEFAULT_USER_SETTINGS['auto_night_mode_time_range_to'] . ')'); } } + + private function CheckAuthClass() + { + if (!class_exists(GROCY_AUTH_CLASS)) + { + throw new EInvalidConfig('Configured AUTH_CLASS "' . GROCY_AUTH_CLASS . '" does not exist'); + } + } } diff --git a/helpers/extensions.php b/helpers/extensions.php index 00095da8..7700f736 100644 --- a/helpers/extensions.php +++ b/helpers/extensions.php @@ -106,17 +106,6 @@ function GetClassConstants($className, $prefix = null) } } -function RandomString($length, $allowedChars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') -{ - $randomString = ''; - for ($i = 0; $i < $length; $i++) - { - $randomString .= $allowedChars[rand(0, strlen($allowedChars) - 1)]; - } - - return $randomString; -} - function IsAssociativeArray(array $array) { $keys = array_keys($array); @@ -278,3 +267,8 @@ function EmptyFolder($folderPath) } } } + +function GetClientUserAgent() +{ + return isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown'; +} diff --git a/localization/strings.pot b/localization/strings.pot index 40825350..396000f3 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -667,10 +667,7 @@ msgstr "" msgid "Are you sure you want to delete product group \"%s\"?" msgstr "" -msgid "Stay logged in permanently" -msgstr "" - -msgid "When not set, you will get logged out at latest after 30 days" +msgid "Remember Me" msgstr "" msgid "Status" @@ -1618,7 +1615,7 @@ msgstr "" msgid "Clear filter" msgstr "" -msgid "Permissions for user %s" +msgid "Permissions" msgstr "" msgid "Are you sure you want to remove full permissions for yourself?" @@ -2483,3 +2480,24 @@ msgid "Pack" msgid_plural "Packs" msgstr[0] "" msgstr[1] "" + +msgid "Manage user sessions" +msgstr "" + +msgid "Sessions" +msgstr "" + +msgid "Token type" +msgstr "" + +msgid "Token hash" +msgstr "" + +msgid "Client" +msgstr "" + +msgid "This session" +msgstr "" + +msgid "Are you sure you want to delete the selected session?" +msgstr "" diff --git a/middleware/Auth/BaseAuthMiddleware.php b/middleware/Auth/BaseAuthMiddleware.php index df88260e..619b062e 100644 --- a/middleware/Auth/BaseAuthMiddleware.php +++ b/middleware/Auth/BaseAuthMiddleware.php @@ -74,10 +74,52 @@ abstract class BaseAuthMiddleware extends BaseMiddleware } } - protected static function SetSessionCookie(string $sessionKey) + protected static function SetSessionCookie(int $tokenType, string $token) { - // Cookie never expires, session validity is up to SessionService - setcookie(SessionService::SESSION_COOKIE_NAME, $sessionKey, PHP_INT_SIZE == 4 ? PHP_INT_MAX : PHP_INT_MAX >> 32); + switch ($tokenType) + { + case SessionService::SESSION_TOKEN_TYPE_ACCESS: + setcookie(SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS, $token, [ + 'expires' => 0, // Browser session end + 'httponly' => true, + 'samesite' => 'Lax', + 'path' => '/' + ]); + break; + + case SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME: + setcookie(SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME, $token, [ + 'expires' => time() + SessionService::SESSION_TOKEN_EXPIRATION_SECONDS_REMEMBER_ME, + 'httponly' => true, + 'samesite' => 'Lax', + 'path' => '/' + ]); + break; + } + } + + protected static function RemoveSessionCookie(int $tokenType) + { + switch ($tokenType) + { + case SessionService::SESSION_TOKEN_TYPE_ACCESS: + setcookie(SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS, '', [ + 'expires' => time() - 3600, + 'httponly' => true, + 'samesite' => 'Lax', + 'path' => '/' + ]); + break; + + case SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME: + setcookie(SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME, '', [ + 'expires' => time() - 3600, + 'httponly' => true, + 'samesite' => 'Lax', + 'path' => '/' + ]); + break; + } } /** diff --git a/middleware/Auth/DefaultAuthMiddleware.php b/middleware/Auth/DefaultAuthMiddleware.php index ee1267cd..a5c2bffc 100644 --- a/middleware/Auth/DefaultAuthMiddleware.php +++ b/middleware/Auth/DefaultAuthMiddleware.php @@ -44,12 +44,18 @@ class DefaultAuthMiddleware extends BaseAuthMiddleware $user = $db->users()->where('username', $postParams['username'])->fetch(); $inputPassword = $postParams['password']; - $stayLoggedInPermanently = $postParams['stay_logged_in'] == 'on'; + $rememberMe = isset($postParams['remember_me']) && $postParams['remember_me'] == 'on'; if ($user !== null && password_verify($inputPassword, $user->password)) { - $sessionKey = SessionService::GetInstance()->CreateSession($user->id, $stayLoggedInPermanently); - self::SetSessionCookie($sessionKey); + $token = SessionService::GetInstance()->CreateToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $user->id, GetClientUserAgent()); + self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_ACCESS, $token); + + if ($rememberMe) + { + $token = SessionService::GetInstance()->CreateToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $user->id, GetClientUserAgent()); + self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $token); + } if (password_needs_rehash($user->password, PASSWORD_ARGON2ID)) { diff --git a/middleware/Auth/LdapAuthMiddleware.php b/middleware/Auth/LdapAuthMiddleware.php index 611fa240..34ab95dc 100644 --- a/middleware/Auth/LdapAuthMiddleware.php +++ b/middleware/Auth/LdapAuthMiddleware.php @@ -74,8 +74,15 @@ class LdapAuthMiddleware extends BaseAuthMiddleware $user = UsersService::GetInstance()->CreateUser($ldapUidAttribute, $ldapFirstName, $ldapLastName, ''); } - $sessionKey = SessionService::GetInstance()->CreateSession($user->id, $postParams['stay_logged_in'] == 'on'); - self::SetSessionCookie($sessionKey); + $token = SessionService::GetInstance()->CreateToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $user->id, GetClientUserAgent()); + self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_ACCESS, $token); + + $rememberMe = isset($postParams['remember_me']) && $postParams['remember_me'] == 'on'; + if ($rememberMe) + { + $token = SessionService::GetInstance()->CreateToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $user->id, GetClientUserAgent()); + self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $token); + } return true; } diff --git a/middleware/Auth/ReverseProxyAuthMiddleware.php b/middleware/Auth/ReverseProxyAuthMiddleware.php index c7906ce6..2fc5ad07 100644 --- a/middleware/Auth/ReverseProxyAuthMiddleware.php +++ b/middleware/Auth/ReverseProxyAuthMiddleware.php @@ -12,7 +12,7 @@ class ReverseProxyAuthMiddleware extends BaseAuthMiddleware { define('GROCY_EXTERNALLY_MANAGED_AUTHENTICATION', true); - // Try to use regular API Key authentiaction (applies when the reverse proxy is configured to be bypassed for API routes) + // Try to use regular API Key authentication (applies when the reverse proxy is configured to be bypassed for API routes) if ($this->IsApiRoute) { $auth = new ApiKeyAuthMiddleware($this->AppContainer, $this->ResponseFactory); diff --git a/middleware/Auth/SessionAuthMiddleware.php b/middleware/Auth/SessionAuthMiddleware.php index 97ef7500..83f2e343 100644 --- a/middleware/Auth/SessionAuthMiddleware.php +++ b/middleware/Auth/SessionAuthMiddleware.php @@ -11,14 +11,33 @@ class SessionAuthMiddleware extends BaseAuthMiddleware { $sessionService = SessionService::GetInstance(); - if (isset($_COOKIE[SessionService::SESSION_COOKIE_NAME]) && $sessionService->IsValidSession($_COOKIE[SessionService::SESSION_COOKIE_NAME])) + // First check if the access token is valid + if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]) && $sessionService->ValidateToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS])) { - return $sessionService->GetUserBySessionKey($_COOKIE[SessionService::SESSION_COOKIE_NAME]); + return $sessionService->GetUserByToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]); } else { - return null; + // We have no valid access token => remove the cookie and check if we have a valid remember me token + self::RemoveSessionCookie(SessionService::SESSION_TOKEN_TYPE_ACCESS); + if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]) && $sessionService->ValidateToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME])) + { + $user = $sessionService->GetUserByToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]); + if ($user !== null) + { + // Remember me token was valid => create a new access token and extend the lifetime of the remember me token cookie + $token = $sessionService->CreateToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $user->id, GetClientUserAgent()); + self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_ACCESS, $token); + self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]); + return $user; + } + } + + // Remember me token was also not valid => remove the cookie + self::RemoveSessionCookie(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME); } + + return null; } public static function ProcessLogin(array $postParams) diff --git a/migrations/0020.sql b/migrations/0020.sql index b3a9f92a..bb78f441 100644 --- a/migrations/0020.sql +++ b/migrations/0020.sql @@ -3,4 +3,4 @@ CREATE TABLE sessions ( session_key TEXT NOT NULL UNIQUE, expires DATETIME, row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) -) +); diff --git a/migrations/0026.sql b/migrations/0026.sql index 68c2f167..05366d8f 100644 --- a/migrations/0026.sql +++ b/migrations/0026.sql @@ -16,5 +16,4 @@ CREATE TABLE sessions ( expires DATETIME, last_used DATETIME, row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) -) - +); diff --git a/migrations/0256.sql b/migrations/0256.sql new file mode 100644 index 00000000..977f5cf4 --- /dev/null +++ b/migrations/0256.sql @@ -0,0 +1,17 @@ +DROP TABLE sessions; + +CREATE TABLE sessions ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + user_id INTEGER NOT NULL, + token_type TINYINT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires DATETIME NOT NULL, + last_used DATETIME, + client_info TEXT, + row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime')) +); + +CREATE INDEX ix_sessions_performance1 ON sessions ( + token_type, + token_hash +); diff --git a/plugins/DemoBarcodeLookupPlugin.php b/plugins/DemoBarcodeLookupPlugin.php index 11fb104f..eba7adb0 100644 --- a/plugins/DemoBarcodeLookupPlugin.php +++ b/plugins/DemoBarcodeLookupPlugin.php @@ -94,7 +94,7 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin } return [ - 'name' => 'LookedUpProduct_' . RandomString(5), + 'name' => 'LookedUpProduct_' . bin2hex(random_bytes(4)), 'location_id' => $locationId, 'qu_id_purchase' => $quId, 'qu_id_stock' => $quId, diff --git a/public/viewjs/managesessions.js b/public/viewjs/managesessions.js new file mode 100644 index 00000000..81826d86 --- /dev/null +++ b/public/viewjs/managesessions.js @@ -0,0 +1,67 @@ +var sessionsTable = $('#sessions-table').DataTable({ + 'order': [[5, 'desc']], + 'columnDefs': [ + { 'orderable': false, 'targets': 0 }, + { 'searchable': false, "targets": 0 } + ].concat($.fn.dataTable.defaults.columnDefs) +}); +$('#sessions-table tbody').removeClass("d-none"); +sessionsTable.columns.adjust().draw(); + +$("#search").on("keyup", Delay(function () +{ + var value = $(this).val(); + if (value === "all") + { + value = ""; + } + + sessionsTable.search(value).draw(); +}, Grocy.FormFocusDelay)); + +$("#clear-filter-button").on("click", function () +{ + $("#search").val(""); + sessionsTable.search("").draw(); +}); + +$(document).on('click', '.session-delete-button', function (e) +{ + var button = $(e.currentTarget); + var objectId = button.attr('data-session-id'); + + bootbox.confirm({ + message: __t('Are you sure you want to delete the selected session?'), + closeButton: false, + className: "text-break", + buttons: { + confirm: { + label: __t('Yes'), + className: 'btn-success' + }, + cancel: { + label: __t('No'), + className: 'btn-danger' + } + }, + callback: function (result) + { + if (result === true) + { + Grocy.Api.Delete('objects/sessions/' + objectId, {}, + function (result) + { + animateCSS("#session-" + objectId + "-row", "fadeOut", function () + { + $("#session-" + objectId + "-row").addClass("d-none").remove(); + }); + }, + function (xhr) + { + console.error(xhr); + } + ); + } + } + }); +}); diff --git a/routes.php b/routes.php index 376c6937..33b9f9bc 100644 --- a/routes.php +++ b/routes.php @@ -55,6 +55,7 @@ $app->group('', function (RouteCollectorProxy $group) $group->get('/users', [UsersController::class, 'UsersList']); $group->get('/user/{userId}', [UsersController::class, 'UserEditForm']); $group->get('/user/{userId}/permissions', [UsersController::class, 'PermissionList']); + $group->get('/user/{userId}/sessions', [UsersController::class, 'SessionList']); $group->get('/usersettings', [UsersController::class, 'UserSettings']); // Stock master data routes diff --git a/services/ApiKeyService.php b/services/ApiKeyService.php index 8514ea4d..87184780 100644 --- a/services/ApiKeyService.php +++ b/services/ApiKeyService.php @@ -9,7 +9,7 @@ class ApiKeyService extends BaseService public function CreateApiKey(string $keyType = self::API_KEY_TYPE_DEFAULT, ?string $description = null) { - $newApiKey = $this->GenerateKey(); + $newApiKey = bin2hex(random_bytes(24)); $apiKeyRow = $this->DB->api_keys()->createRow([ 'api_key' => $newApiKey, @@ -97,9 +97,4 @@ class ApiKeyService extends BaseService { $this->DB->api_keys()->where('api_key', $apiKey)->delete(); } - - private function GenerateKey() - { - return RandomString(50); - } } diff --git a/services/ApplicationService.php b/services/ApplicationService.php index 88f50b86..0b51f4e5 100644 --- a/services/ApplicationService.php +++ b/services/ApplicationService.php @@ -71,7 +71,7 @@ class ApplicationService extends BaseService 'sqlite_version' => $sqliteVersion, 'db_version' => $this->DB->migrations()->max('migration'), 'os' => php_uname('s') . ' ' . php_uname('r') . ' ' . php_uname('v') . ' ' . php_uname('m'), - 'client' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown' + 'client' => GetClientUserAgent() ]; } diff --git a/services/DatabaseMigrationService.php b/services/DatabaseMigrationService.php index 86ffe164..00ba4db6 100644 --- a/services/DatabaseMigrationService.php +++ b/services/DatabaseMigrationService.php @@ -40,6 +40,8 @@ class DatabaseMigrationService extends BaseService { DatabaseService::GetInstance()->ExecuteDbStatement('VACUUM'); } + + SessionService::GetInstance()->DeleteExpiredTokens(); } private function ExecutePhpMigrationWhenNeeded(int $migrationId, string $phpFile, int &$migrationCounter) diff --git a/services/SessionService.php b/services/SessionService.php index c40bc8f2..ce6c8214 100644 --- a/services/SessionService.php +++ b/services/SessionService.php @@ -4,37 +4,78 @@ namespace Grocy\Services; class SessionService extends BaseService { - const SESSION_COOKIE_NAME = 'grocy_session'; + const int SESSION_TOKEN_TYPE_ACCESS = 1; + const int SESSION_TOKEN_TYPE_REMEMBER_ME = 2; + const string SESSION_TOKEN_COOKIE_NAME_ACCESS = 'grocy_session_access_token'; + const string SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME = 'grocy_session_remember_me_token'; + const int SESSION_TOKEN_EXPIRATION_SECONDS_ACCESS = 864000; // 10 Days + const int SESSION_TOKEN_EXPIRATION_SECONDS_REMEMBER_ME = 15552000; // 180 Days - public function CreateSession($userId, $stayLoggedInPermanently = false) + public function CreateToken(int $tokenType, int $userId, string $clientInfo) { - $newSessionKey = $this->GenerateKey(); - $expires = date('Y-m-d H:i:s', time() + 2592000); + $newToken = bin2hex(random_bytes(32)); - // Default is that sessions expire in 30 days - if ($stayLoggedInPermanently === true) + $expires = date('Y-m-d H:i:s'); + switch ($tokenType) { - $expires = date('Y-m-d H:i:s', PHP_INT_SIZE == 4 ? PHP_INT_MAX : PHP_INT_MAX >> 32); // Never + case self::SESSION_TOKEN_TYPE_ACCESS: + $expires = date('Y-m-d H:i:s', time() + self::SESSION_TOKEN_EXPIRATION_SECONDS_ACCESS); + break; + + case self::SESSION_TOKEN_TYPE_REMEMBER_ME: + $expires = date('Y-m-d H:i:s', time() + self::SESSION_TOKEN_EXPIRATION_SECONDS_REMEMBER_ME); + break; } $sessionRow = $this->DB->sessions()->createRow([ 'user_id' => $userId, - 'session_key' => $newSessionKey, - 'expires' => $expires + 'token_type' => $tokenType, + 'token_hash' => hash('sha256', $newToken), + 'expires' => $expires, + 'client_info' => $clientInfo, + 'last_used' => date('Y-m-d H:i:s', time()) ]); $sessionRow->save(); - return $newSessionKey; + $this->DeleteExpiredTokens(); + + return $newToken; } - public function GetDefaultUser() + public function DeleteToken(int $tokenType, string $token) { - return $this->DB->users()->orderBy('id')->limit(1)->fetch(); + $this->DB->sessions()->where('token_type = :1 AND token_hash = :2', $tokenType, hash('sha256', $token))->delete(); } - public function GetUserBySessionKey($sessionKey) + public function ValidateToken(int $tokenType, string $token) { - $sessionRow = $this->DB->sessions()->where('session_key', $sessionKey)->fetch(); + if (empty($token)) + { + return false; + } + + $sessionRow = $this->DB->sessions()->where('token_type = :1 AND token_hash = :2 AND expires > :3', $tokenType, hash('sha256', $token), date('Y-m-d H:i:s', time()))->fetch(); + if ($sessionRow !== null) + { + // This should not change the database file modification time as this is used + // to determine if REALLY something has changed + $dbModTime = DatabaseService::GetInstance()->GetDbChangedTime(); + $sessionRow->update([ + 'last_used' => date('Y-m-d H:i:s', time()) + ]); + DatabaseService::GetInstance()->SetDbChangedTime($dbModTime); + + return true; + } + else + { + return false; + } + } + + public function GetUserByToken(int $tokenType, string $token) + { + $sessionRow = $this->DB->sessions()->where('token_type = :1 AND token_hash = :2', $tokenType, hash('sha256', $token))->fetch(); if ($sessionRow !== null) { return $this->DB->users($sessionRow->user_id); @@ -43,41 +84,13 @@ class SessionService extends BaseService return null; } - public function IsValidSession($sessionKey) + public function GetDefaultUser() { - if ($sessionKey === null || empty($sessionKey)) - { - return false; - } - else - { - $sessionRow = $this->DB->sessions()->where('session_key = :1 AND expires > :2', $sessionKey, date('Y-m-d H:i:s', time()))->fetch(); - if ($sessionRow !== null) - { - // This should not change the database file modification time as this is used - // to determine if REALLY something has changed - $dbModTime = DatabaseService::GetInstance()->GetDbChangedTime(); - $sessionRow->update([ - 'last_used' => date('Y-m-d H:i:s', time()) - ]); - DatabaseService::GetInstance()->SetDbChangedTime($dbModTime); - - return true; - } - else - { - return false; - } - } + return $this->DB->users()->orderBy('id')->limit(1)->fetch(); } - public function RemoveSession($sessionKey) + public function DeleteExpiredTokens() { - $this->DB->sessions()->where('session_key', $sessionKey)->delete(); - } - - private function GenerateKey() - { - return RandomString(50); + $this->DB->sessions()->where('expires < :1', date('Y-m-d H:i:s', time()))->delete(); } } diff --git a/views/login.blade.php b/views/login.blade.php index 4d5afb41..b025bc2c 100644 --- a/views/login.blade.php +++ b/views/login.blade.php @@ -41,15 +41,11 @@
+ id="remember_me" + name="remember_me">
diff --git a/views/managesessions.blade.php b/views/managesessions.blade.php new file mode 100644 index 00000000..a82c05cd --- /dev/null +++ b/views/managesessions.blade.php @@ -0,0 +1,122 @@ +@php require_frontend_packages(['datatables', 'animatecss']); @endphp + +@extends('layout.default') + +@section('title', $__t('Sessions')) + +@section('content') +
+
+ +
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+ +
+
+ + + + + + + + + + + + + + @foreach($sessionRows as $sessionRow) + + + + + + + + + + @endforeach + +
+ {{ $__t('Token type') }}{{ $__t('Token hash') }}{{ $__t('Created') }}{{ $__t('Expires') }}{{ $__t('Last used') }}{{ $__t('Client') }}
+ + + + + @if($sessionRow->token_type == 1) access @elseif($sessionRow->token_type == 2) remember_me @endif + + {{ substr($sessionRow->token_hash, 0, 8) }} @if(in_array($sessionRow->token_hash, $thisSessionTokenHashes)) {{ $__t('This session') }} @endif + + {{ $sessionRow->row_created_timestamp }} + + + {{ $sessionRow->expires }} + + + @if(empty($sessionRow->last_used)){{ $__t('never') }}@else{{ $sessionRow->last_used }}@endif + + + {{ $sessionRow->client_info }} +
+
+
+@stop diff --git a/views/userpermissions.blade.php b/views/userpermissions.blade.php index 6b52a6a8..da5e5cc9 100644 --- a/views/userpermissions.blade.php +++ b/views/userpermissions.blade.php @@ -1,6 +1,6 @@ @extends('layout.default') -@section('title', $__t('Permissions for user %s', GetUserDisplayName($user))) +@section('title', $__t('Permissions')) @push('pageScripts')