feat(security): add session timeout + transaction wrapping (B1)

Session timeout: configurable idle (default 30min) and absolute (default 8h)
timeouts via DB settings, enforced in web/index.php on every request.
Timestamps set at login in action layer; graceful fallback for pre-existing
sessions.

Transaction wrapping: UserAccountService.createFromAdmin/register, roles
create/edit, and permissions edit now wrap multi-step DB operations in
transactions to guarantee atomicity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 19:16:26 +01:00
parent 8d6cfa2fbc
commit 11ca546eae
17 changed files with 415 additions and 103 deletions

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Service\User;
use MintyPHP\I18n;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
@@ -19,7 +20,8 @@ class UserAccountService
private readonly UserSettingsGateway $settingsGateway,
private readonly UserScopeGateway $scopeGateway,
private readonly UserDirectoryGateway $directoryGateway,
private readonly SystemAuditService $systemAuditService
private readonly SystemAuditService $systemAuditService,
private readonly DatabaseSessionRepository $databaseSessionRepository
) {
}
@@ -179,66 +181,82 @@ class UserAccountService
}
$activeChangedAt = gmdate('Y-m-d H:i:s');
$created = $this->userWriteRepository->create([
'first_name' => $form['first_name'],
'last_name' => $form['last_name'],
'email' => $form['email'],
'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null,
'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null,
'phone' => $form['phone'] !== '' ? $form['phone'] : null,
'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null,
'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null,
'address' => $form['address'] !== '' ? $form['address'] : null,
'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null,
'city' => $form['city'] !== '' ? $form['city'] : null,
'country' => $form['country'] !== '' ? $form['country'] : null,
'region' => $form['region'] !== '' ? $form['region'] : null,
'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null,
'password' => $password,
'locale' => $form['locale'],
'totp_secret' => $form['totp_secret'],
'theme' => $form['theme'],
'primary_tenant_id' => $primaryTenantId > 0 ? $primaryTenantId : null,
'active' => $form['active'],
'created_by' => $currentUserId > 0 ? $currentUserId : null,
'active_changed_at' => $activeChangedAt,
'active_changed_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$created) {
$transactionStarted = false;
try {
$this->databaseSessionRepository->beginTransaction();
$transactionStarted = true;
$created = $this->userWriteRepository->create([
'first_name' => $form['first_name'],
'last_name' => $form['last_name'],
'email' => $form['email'],
'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null,
'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null,
'phone' => $form['phone'] !== '' ? $form['phone'] : null,
'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null,
'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null,
'address' => $form['address'] !== '' ? $form['address'] : null,
'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null,
'city' => $form['city'] !== '' ? $form['city'] : null,
'country' => $form['country'] !== '' ? $form['country'] : null,
'region' => $form['region'] !== '' ? $form['region'] : null,
'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null,
'password' => $password,
'locale' => $form['locale'],
'totp_secret' => $form['totp_secret'],
'theme' => $form['theme'],
'primary_tenant_id' => $primaryTenantId > 0 ? $primaryTenantId : null,
'active' => $form['active'],
'created_by' => $currentUserId > 0 ? $currentUserId : null,
'active_changed_at' => $activeChangedAt,
'active_changed_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$created) {
$this->rollbackQuietly();
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
}
$createdUser = $this->userReadRepository->findByEmail($form['email']);
$uuid = $createdUser['uuid'] ?? null;
$userId = (int) ($createdUser['id'] ?? 0);
if ($userId > 0 && $tenantIds) {
$this->userAssignmentService->syncTenants($userId, $tenantIds);
}
$roleIds = $this->userAssignmentService->normalizeIdInput($input['role_ids'] ?? []);
if (!$roleIds) {
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
if ($defaultRoleId) {
$roleIds = [$defaultRoleId];
}
}
if ($userId > 0 && $roleIds) {
$this->userAssignmentService->syncRoles($userId, $roleIds);
}
$departmentIds = $this->userAssignmentService->normalizeIdInput($input['department_ids'] ?? []);
if (!$departmentIds) {
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
if ($defaultDepartmentId) {
$departmentIds = [$defaultDepartmentId];
}
}
if ($userId > 0 && $departmentIds) {
$this->userAssignmentService->syncDepartments($userId, $departmentIds);
}
$this->databaseSessionRepository->commitTransaction();
$transactionStarted = false;
} catch (\Throwable $exception) {
if ($transactionStarted) {
$this->rollbackQuietly();
}
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
}
$createdUser = $this->userReadRepository->findByEmail($form['email']);
$uuid = $createdUser['uuid'] ?? null;
$userId = (int) ($createdUser['id'] ?? 0);
if ($userId > 0 && $tenantIds) {
$this->userAssignmentService->syncTenants($userId, $tenantIds);
}
$roleIds = $this->userAssignmentService->normalizeIdInput($input['role_ids'] ?? []);
if (!$roleIds) {
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
if ($defaultRoleId) {
$roleIds = [$defaultRoleId];
}
}
if ($userId > 0 && $roleIds) {
$this->userAssignmentService->syncRoles($userId, $roleIds);
}
$departmentIds = $this->userAssignmentService->normalizeIdInput($input['department_ids'] ?? []);
if (!$departmentIds) {
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
if ($defaultDepartmentId) {
$departmentIds = [$defaultDepartmentId];
}
}
if ($userId > 0 && $departmentIds) {
$this->userAssignmentService->syncDepartments($userId, $departmentIds);
}
$this->systemAuditService->record('admin.users.create', 'success', [
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
'target_type' => 'user',
@@ -476,41 +494,57 @@ class UserAccountService
$defaultTheme = $this->settingsGateway->getAppTheme();
$defaultTenantId = $this->settingsGateway->getDefaultTenantId();
$activeChangedAt = gmdate('Y-m-d H:i:s');
$created = $this->userWriteRepository->create([
'first_name' => $form['first_name'],
'last_name' => $form['last_name'],
'email' => $form['email'],
'password' => $password,
'locale' => I18n::$locale,
'totp_secret' => '',
'theme' => $this->normalizeTheme($defaultTheme ?? 'light'),
'primary_tenant_id' => $defaultTenantId ?: null,
'active' => 1,
'created_by' => null,
'active_changed_at' => $activeChangedAt,
'active_changed_by' => null,
]);
if (!$created) {
$transactionStarted = false;
try {
$this->databaseSessionRepository->beginTransaction();
$transactionStarted = true;
$created = $this->userWriteRepository->create([
'first_name' => $form['first_name'],
'last_name' => $form['last_name'],
'email' => $form['email'],
'password' => $password,
'locale' => I18n::$locale,
'totp_secret' => '',
'theme' => $this->normalizeTheme($defaultTheme ?? 'light'),
'primary_tenant_id' => $defaultTenantId ?: null,
'active' => 1,
'created_by' => null,
'active_changed_at' => $activeChangedAt,
'active_changed_by' => null,
]);
if (!$created) {
$this->rollbackQuietly();
return ['ok' => false, 'error' => t('User can not be registered')];
}
$createdUser = $this->userReadRepository->findByEmail($form['email']);
$userId = (int) ($createdUser['id'] ?? 0);
if ($userId > 0) {
if ($defaultTenantId) {
$this->userAssignmentService->syncTenants($userId, [$defaultTenantId]);
}
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
if ($defaultRoleId) {
$this->userAssignmentService->syncRoles($userId, [$defaultRoleId]);
}
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
if ($defaultDepartmentId) {
$this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId]);
}
}
$this->databaseSessionRepository->commitTransaction();
$transactionStarted = false;
} catch (\Throwable $exception) {
if ($transactionStarted) {
$this->rollbackQuietly();
}
return ['ok' => false, 'error' => t('User can not be registered')];
}
$createdUser = $this->userReadRepository->findByEmail($form['email']);
$userId = (int) ($createdUser['id'] ?? 0);
if ($userId > 0) {
if ($defaultTenantId) {
$this->userAssignmentService->syncTenants($userId, [$defaultTenantId]);
}
$defaultRoleId = $this->settingsGateway->getDefaultRoleId();
if ($defaultRoleId) {
$this->userAssignmentService->syncRoles($userId, [$defaultRoleId]);
}
$defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId();
if ($defaultDepartmentId) {
$this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId]);
}
}
return ['ok' => true];
}
@@ -651,6 +685,15 @@ class UserAccountService
];
}
private function rollbackQuietly(): void
{
try {
$this->databaseSessionRepository->rollbackTransaction();
} catch (\Throwable) {
// Swallow — the original exception is more important.
}
}
private function normalizeTheme($value): string
{
$theme = strtolower(trim((string) $value));