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:
@@ -21,6 +21,7 @@ use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSessionGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||
use MintyPHP\Service\Settings\SettingsUserLifecycleGateway;
|
||||
@@ -36,6 +37,7 @@ final class SettingsRegistrar implements ContainerRegistrar
|
||||
$container->set(SettingsAppGateway::class, static fn (AppContainer $c): SettingsAppGateway => $c->get(SettingServicesFactory::class)->createSettingsAppGateway());
|
||||
$container->set(SettingsApiPolicyGateway::class, static fn (AppContainer $c): SettingsApiPolicyGateway => $c->get(SettingServicesFactory::class)->createSettingsApiPolicyGateway());
|
||||
$container->set(SettingsUserLifecycleGateway::class, static fn (AppContainer $c): SettingsUserLifecycleGateway => $c->get(SettingServicesFactory::class)->createSettingsUserLifecycleGateway());
|
||||
$container->set(SettingsSessionGateway::class, static fn (AppContainer $c): SettingsSessionGateway => $c->get(SettingServicesFactory::class)->createSettingsSessionGateway());
|
||||
$container->set(SettingsSystemAuditGateway::class, static fn (AppContainer $c): SettingsSystemAuditGateway => $c->get(SettingServicesFactory::class)->createSettingsSystemAuditGateway());
|
||||
$container->set(SettingsFrontendTelemetryGateway::class, static fn (AppContainer $c): SettingsFrontendTelemetryGateway => $c->get(SettingServicesFactory::class)->createSettingsFrontendTelemetryGateway());
|
||||
$container->set(SettingsMicrosoftGateway::class, static fn (AppContainer $c): SettingsMicrosoftGateway => $c->get(SettingServicesFactory::class)->createSettingsMicrosoftGateway());
|
||||
|
||||
@@ -196,6 +196,7 @@ class AuthService
|
||||
}
|
||||
|
||||
$this->userWriteRepository->updateLastLogin($userId, $loginProvider);
|
||||
|
||||
$this->recordAuthEvent('auth.login.success', 'success', [
|
||||
'actor_user_id' => $userId,
|
||||
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
||||
|
||||
@@ -33,6 +33,8 @@ final class SettingKeys
|
||||
public const FRONTEND_TELEMETRY_ENABLED_KEY = 'frontend_telemetry_enabled';
|
||||
public const FRONTEND_TELEMETRY_SAMPLE_RATE_KEY = 'frontend_telemetry_sample_rate';
|
||||
public const FRONTEND_TELEMETRY_ALLOWED_EVENTS_KEY = 'frontend_telemetry_allowed_events';
|
||||
public const SESSION_IDLE_TIMEOUT_MINUTES_KEY = 'session_idle_timeout_minutes';
|
||||
public const SESSION_ABSOLUTE_TIMEOUT_HOURS_KEY = 'session_absolute_timeout_hours';
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ class SettingServicesFactory
|
||||
private ?SettingsSystemAuditGateway $settingsSystemAuditGateway = null;
|
||||
private ?SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway = null;
|
||||
private ?SettingsMicrosoftGateway $settingsMicrosoftGateway = null;
|
||||
private ?SettingsSessionGateway $settingsSessionGateway = null;
|
||||
private ?SettingsSmtpGateway $settingsSmtpGateway = null;
|
||||
|
||||
public function __construct(
|
||||
@@ -83,6 +84,11 @@ class SettingServicesFactory
|
||||
);
|
||||
}
|
||||
|
||||
public function createSettingsSessionGateway(): SettingsSessionGateway
|
||||
{
|
||||
return $this->settingsSessionGateway ??= new SettingsSessionGateway($this->createSettingsMetadataGateway());
|
||||
}
|
||||
|
||||
public function createSettingsSmtpGateway(): SettingsSmtpGateway
|
||||
{
|
||||
return $this->settingsSmtpGateway ??= new SettingsSmtpGateway($this->createSettingsMetadataGateway());
|
||||
|
||||
79
lib/Service/Settings/SettingsSessionGateway.php
Normal file
79
lib/Service/Settings/SettingsSessionGateway.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Settings;
|
||||
|
||||
class SettingsSessionGateway
|
||||
{
|
||||
private const IDLE_TIMEOUT_MINUTES_FALLBACK = 30;
|
||||
private const ABSOLUTE_TIMEOUT_HOURS_FALLBACK = 8;
|
||||
private const IDLE_TIMEOUT_MINUTES_MIN = 5;
|
||||
private const IDLE_TIMEOUT_MINUTES_MAX = 1440;
|
||||
private const ABSOLUTE_TIMEOUT_HOURS_MIN = 1;
|
||||
private const ABSOLUTE_TIMEOUT_HOURS_MAX = 72;
|
||||
|
||||
public function __construct(private readonly SettingsMetadataGateway $settingsMetadataGateway)
|
||||
{
|
||||
}
|
||||
|
||||
public function getSessionIdleTimeoutMinutes(): int
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getInt(SettingKeys::SESSION_IDLE_TIMEOUT_MINUTES_KEY);
|
||||
if ($value !== null && $this->isValidIdleTimeout($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return self::IDLE_TIMEOUT_MINUTES_FALLBACK;
|
||||
}
|
||||
|
||||
public function setSessionIdleTimeoutMinutes(?int $minutes, ?string $description = null): bool
|
||||
{
|
||||
$value = $minutes ?? self::IDLE_TIMEOUT_MINUTES_FALLBACK;
|
||||
if (!$this->isValidIdleTimeout($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$desc = $description ?? 'setting.session_idle_timeout_minutes';
|
||||
return $this->settingsMetadataGateway->set(SettingKeys::SESSION_IDLE_TIMEOUT_MINUTES_KEY, (string) $value, $desc);
|
||||
}
|
||||
|
||||
public function getSessionAbsoluteTimeoutHours(): int
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getInt(SettingKeys::SESSION_ABSOLUTE_TIMEOUT_HOURS_KEY);
|
||||
if ($value !== null && $this->isValidAbsoluteTimeout($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return self::ABSOLUTE_TIMEOUT_HOURS_FALLBACK;
|
||||
}
|
||||
|
||||
public function setSessionAbsoluteTimeoutHours(?int $hours, ?string $description = null): bool
|
||||
{
|
||||
$value = $hours ?? self::ABSOLUTE_TIMEOUT_HOURS_FALLBACK;
|
||||
if (!$this->isValidAbsoluteTimeout($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$desc = $description ?? 'setting.session_absolute_timeout_hours';
|
||||
return $this->settingsMetadataGateway->set(SettingKeys::SESSION_ABSOLUTE_TIMEOUT_HOURS_KEY, (string) $value, $desc);
|
||||
}
|
||||
|
||||
public function getSessionIdleTimeoutSeconds(): int
|
||||
{
|
||||
return $this->getSessionIdleTimeoutMinutes() * 60;
|
||||
}
|
||||
|
||||
public function getSessionAbsoluteTimeoutSeconds(): int
|
||||
{
|
||||
return $this->getSessionAbsoluteTimeoutHours() * 3600;
|
||||
}
|
||||
|
||||
private function isValidIdleTimeout(int $minutes): bool
|
||||
{
|
||||
return $minutes >= self::IDLE_TIMEOUT_MINUTES_MIN && $minutes <= self::IDLE_TIMEOUT_MINUTES_MAX;
|
||||
}
|
||||
|
||||
private function isValidAbsoluteTimeout(int $hours): bool
|
||||
{
|
||||
return $hours >= self::ABSOLUTE_TIMEOUT_HOURS_MIN && $hours <= self::ABSOLUTE_TIMEOUT_HOURS_MAX;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -45,7 +45,8 @@ class UserServicesFactory
|
||||
$this->createUserSettingsGateway(),
|
||||
$this->createUserScopeGateway(),
|
||||
$this->createUserDirectoryGateway(),
|
||||
$this->auditServicesFactory->createSystemAuditService()
|
||||
$this->auditServicesFactory->createSystemAuditService(),
|
||||
$this->databaseSessionRepository
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user