Optimized session handling

This commit is contained in:
Bernd Bestel 2026-08-26 20:08:22 +02:00
parent acd061e2cd
commit b6c07a51ce
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
30 changed files with 502 additions and 149 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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_')

View File

@ -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('/'));
}

View File

@ -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')
]);
}
}

View File

@ -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": {

View File

@ -4,22 +4,29 @@ namespace Grocy\Helpers;
class EInvalidConfig extends \Exception
{
public function __construct(string $message)
{
$message .= '<br><br>----------<br>Check your "config.php" file (which is in your data directory: "' . realpath(GROCY_DATAPATH) . '").<br>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');
}
}
}

View File

@ -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';
}

View File

@ -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 ""

View File

@ -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;
}
}
/**

View File

@ -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))
{

View File

@ -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;
}

View File

@ -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);

View File

@ -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)

View File

@ -3,4 +3,4 @@ CREATE TABLE sessions (
session_key TEXT NOT NULL UNIQUE,
expires DATETIME,
row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
)
);

View File

@ -16,5 +16,4 @@ CREATE TABLE sessions (
expires DATETIME,
last_used DATETIME,
row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
)
);

17
migrations/0256.sql Normal file
View File

@ -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
);

View File

@ -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,

View File

@ -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);
}
);
}
}
});
});

View File

@ -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

View File

@ -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);
}
}

View File

@ -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()
];
}

View File

@ -40,6 +40,8 @@ class DatabaseMigrationService extends BaseService
{
DatabaseService::GetInstance()->ExecuteDbStatement('VACUUM');
}
SessionService::GetInstance()->DeleteExpiredTokens();
}
private function ExecutePhpMigrationWhenNeeded(int $migrationId, string $phpFile, int &$migrationCounter)

View File

@ -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();
}
}

View File

@ -41,15 +41,11 @@
<div class="custom-control custom-checkbox">
<input type="checkbox"
class="form-check-input custom-control-input"
id="stay_logged_in"
name="stay_logged_in">
id="remember_me"
name="remember_me">
<label class="form-check-label custom-control-label"
for="stay_logged_in">
{{ $__t('Stay logged in permanently') }}
<i class="fa-solid fa-question-circle text-muted"
data-toggle="tooltip"
data-trigger="hover click"
title="{{ $__t('When not set, you will get logged out at latest after 30 days') }}"></i>
for="remember_me">
{{ $__t('Remember Me') }}
</label>
</div>
</div>

View File

@ -0,0 +1,122 @@
@php require_frontend_packages(['datatables', 'animatecss']); @endphp
@extends('layout.default')
@section('title', $__t('Sessions'))
@section('content')
<div class="row">
<div class="col">
<div class="title-related-links">
<h2 class="title">
@yield('title')
<span class="text-muted small">{{ $__t('User') }} <strong>{{ GetUserDisplayName($user) }}</strong></span>
</h2>
<div class="float-right @if($embedded) pr-5 @endif">
<button class="btn btn-outline-dark d-md-none mt-2 order-1 order-md-3"
type="button"
data-toggle="collapse"
data-target="#table-filter-row">
<i class="fa-solid fa-filter"></i>
</button>
<button class="btn btn-outline-dark d-md-none mt-2 order-1 order-md-3"
type="button"
data-toggle="collapse"
data-target="#related-links">
<i class="fa-solid fa-ellipsis-v"></i>
</button>
</div>
</div>
</div>
</div>
<hr class="my-2">
<div class="row collapse d-md-flex"
id="table-filter-row">
<div class="col-12 col-md-6 col-xl-3">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa-solid fa-search"></i></span>
</div>
<input type="text"
id="search"
class="form-control"
placeholder="{{ $__t('Search') }}">
</div>
</div>
<div class="col">
<div class="float-right">
<button id="clear-filter-button"
class="btn btn-sm btn-outline-info"
data-toggle="tooltip"
title="{{ $__t('Clear filter') }}">
<i class="fa-solid fa-filter-circle-xmark"></i>
</button>
</div>
</div>
</div>
<div class="row">
<div class="col">
<table id="sessions-table"
class="table table-sm table-striped nowrap w-100">
<thead>
<tr>
<th class="border-right"><a class="text-muted change-table-columns-visibility-button"
data-toggle="tooltip"
title="{{ $__t('Table options') }}"
data-table-selector="#sessions-table"
href="#"><i class="fa-solid fa-eye"></i></a>
</th>
<th class="allow-grouping">{{ $__t('Token type') }}</th>
<th>{{ $__t('Token hash') }}</th>
<th>{{ $__t('Created') }}</th>
<th>{{ $__t('Expires') }}</th>
<th>{{ $__t('Last used') }}</th>
<th>{{ $__t('Client') }}</th>
</tr>
</thead>
<tbody class="d-none">
@foreach($sessionRows as $sessionRow)
<tr id="session-{{ $sessionRow->id }}-row">
<td class="fit-content border-right">
<a class="btn btn-danger btn-sm session-delete-button"
href="#"
data-session-id="{{ $sessionRow->id }}"
data-toggle="tooltip"
title="{{ $__t('Delete this item') }}">
<i class="fa-solid fa-trash"></i>
</a>
</td>
<td>
@if($sessionRow->token_type == 1) access @elseif($sessionRow->token_type == 2) remember_me @endif
</td>
<td>
{{ substr($sessionRow->token_hash, 0, 8) }} @if(in_array($sessionRow->token_hash, $thisSessionTokenHashes)) <span class="badge badge-info">{{ $__t('This session') }}</span> @endif
</td>
<td>
{{ $sessionRow->row_created_timestamp }}
<time class="timeago timeago-contextual"
datetime="{{ $sessionRow->row_created_timestamp }}"></time>
</td>
<td>
{{ $sessionRow->expires }}
<time class="timeago timeago-contextual"
datetime="{{ $sessionRow->expires }}"></time>
</td>
<td>
@if(empty($sessionRow->last_used)){{ $__t('never') }}@else{{ $sessionRow->last_used }}@endif
<time class="timeago timeago-contextual"
datetime="{{ $sessionRow->last_used }}"></time>
</td>
<td>
{{ $sessionRow->client_info }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@stop

View File

@ -1,6 +1,6 @@
@extends('layout.default')
@section('title', $__t('Permissions for user %s', GetUserDisplayName($user)))
@section('title', $__t('Permissions'))
@push('pageScripts')
<script>
@ -19,7 +19,10 @@
@section('content')
<div class="row">
<div class="col">
<h2 class="title">@yield('title')</h2>
<h2 class="title">
@yield('title')
<span class="text-muted small">{{ $__t('User') }} <strong>{{ GetUserDisplayName($user) }}</strong></span>
</h2>
</div>
</div>

View File

@ -92,20 +92,26 @@
@foreach($users as $user)
<tr>
<td class="fit-content border-right">
@if(!GROCY_IS_EMBEDDED_INSTALL && !GROCY_DISABLE_AUTH)
<a class="btn btn-primary btn-sm"
href="{{ $U('/user/' . $user->id . '/permissions') }}"
data-toggle="tooltip"
title="{{ $__t('Configure user permissions') }}">
<i class="fa-solid fa-lock"></i>
</a>
<a class="btn btn-primary btn-sm"
href="{{ $U('/user/' . $user->id . '/sessions') }}"
data-toggle="tooltip"
title="{{ $__t('Manage user sessions') }}">
<i class="fa-solid fa-tower-broadcast"></i>
</a>
@endif
<a class="btn btn-info btn-sm"
href="{{ $U('/user/') }}{{ $user->id }}"
data-toggle="tooltip"
title="{{ $__t('Edit this item') }}">
<i class="fa-solid fa-edit"></i>
</a>
@if(!GROCY_IS_EMBEDDED_INSTALL && !GROCY_DISABLE_AUTH)
<a class="btn btn-info btn-sm"
href="{{ $U('/user/' . $user->id . '/permissions') }}"
data-toggle="tooltip"
title="{{ $__t('Configure user permissions') }}">
<i class="fa-solid fa-lock"></i>
</a>
@endif
<a class="btn btn-danger btn-sm user-delete-button @if($user->id == GROCY_USER_ID) disabled @endif"
href="#"
data-user-id="{{ $user->id }}"