From ce3028ce56b85525805f4c194a82587f1da4065d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96zt=C3=BCrk?= Date: Tue, 28 Apr 2026 00:29:03 +0300 Subject: [PATCH] =?UTF-8?q?ConcurrentUser=20=C3=87al=C4=B1=C5=9Fmas=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/MailQueueWorker.cs | 4 +- .../FileManagementAppService.cs | 14 +-- .../Identity/PlatformIdentityAppService.cs | 4 +- .../Seeds/HostData.json | 9 +- .../Seeds/LanguagesData.json | 10 +- .../Seeds/ListFormSeeder_Saas.cs | 54 +++++--- .../Enums/WorkerTypeEnum.cs | 1 + .../ISessionCleanupWorker.cs | 9 ++ .../PlatformBackgroundWorker.cs | 4 + .../DeveloperKit/DynamicService.cs | 2 +- .../Tenant/Administration/Website/BlogPost.cs | 2 +- .../Seeds/TenantDataSeeder.cs | 2 +- .../DynamicAssemblyRegistrationService.cs | 4 +- .../Identity/PlatformSessionCleanupWorker.cs | 104 +++++++++++++++ .../PlatformSessionRevocationHandler.cs | 87 +++++++++++++ .../Identity/PlatformSignInManager.cs | 52 +++++--- .../Identity/PlatformTokenController.cs | 119 ++++++++++++++++++ 17 files changed, 424 insertions(+), 57 deletions(-) create mode 100644 api/src/Sozsoft.Platform.Domain/BackgroundWorkers/ISessionCleanupWorker.cs create mode 100644 api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionCleanupWorker.cs create mode 100644 api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionRevocationHandler.cs diff --git a/api/modules/Sozsoft.MailQueue/Domain/MailQueueWorker.cs b/api/modules/Sozsoft.MailQueue/Domain/MailQueueWorker.cs index 9677686..0a374f6 100644 --- a/api/modules/Sozsoft.MailQueue/Domain/MailQueueWorker.cs +++ b/api/modules/Sozsoft.MailQueue/Domain/MailQueueWorker.cs @@ -28,7 +28,7 @@ public class MailQueueWorker : BackgroundWorkerBase public IGuidGenerator GuidGenerator { get; } public MailQueueWorker( - ISozsoftEmailSender ErpEmailSender, + ISozsoftEmailSender erpEmailSender, IRepository repository, IClock clock, IConfiguration configuration, @@ -39,7 +39,7 @@ public class MailQueueWorker : BackgroundWorkerBase IGuidGenerator guidGenerator ) { - ErpEmailSender = ErpEmailSender; + ErpEmailSender = erpEmailSender; Repository = repository; Clock = clock; Configuration = configuration; diff --git a/api/src/Sozsoft.Platform.Application/FileManagement/FileManagementAppService.cs b/api/src/Sozsoft.Platform.Application/FileManagement/FileManagementAppService.cs index fd78ae6..3691118 100644 --- a/api/src/Sozsoft.Platform.Application/FileManagement/FileManagementAppService.cs +++ b/api/src/Sozsoft.Platform.Application/FileManagement/FileManagementAppService.cs @@ -338,8 +338,8 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe Id = EncodePathAsId(newFolderPath), Name = input.Name, Type = "folder", - CreatedAt = DateTime.UtcNow, - ModifiedAt = DateTime.UtcNow, + CreatedAt = DateTime.Now, + ModifiedAt = DateTime.Now, Path = newFolderPath, ParentId = decodedParentId ?? string.Empty, IsReadOnly = false, @@ -430,8 +430,8 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe Size = fileSize, Extension = normalizedExtension, MimeType = GetMimeType(normalizedExtension), - CreatedAt = DateTime.UtcNow, - ModifiedAt = DateTime.UtcNow, + CreatedAt = DateTime.Now, + ModifiedAt = DateTime.Now, Path = filePath, ParentId = decodedParentId ?? string.Empty, IsReadOnly = false, @@ -500,13 +500,13 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe // Update metadata metadata.Name = input.Name; metadata.Path = newPath; - metadata.ModifiedAt = DateTime.UtcNow; + metadata.ModifiedAt = DateTime.Now; // Update parent index var itemToUpdate = parentItems.First(x => x.Id == id); itemToUpdate.Name = input.Name; itemToUpdate.Path = newPath; - itemToUpdate.ModifiedAt = DateTime.UtcNow; + itemToUpdate.ModifiedAt = DateTime.Now; await SaveFolderIndexAsync(parentItems, tenantId, metadata.ParentId == string.Empty ? null : metadata.ParentId); @@ -570,7 +570,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe // Update metadata metadata.Path = newPath; metadata.ParentId = input.TargetFolderId ?? string.Empty; - metadata.ModifiedAt = DateTime.UtcNow; + metadata.ModifiedAt = DateTime.Now; // Add to target targetItems.Add(metadata); diff --git a/api/src/Sozsoft.Platform.Application/Identity/PlatformIdentityAppService.cs b/api/src/Sozsoft.Platform.Application/Identity/PlatformIdentityAppService.cs index 1a5c8f0..3f704e3 100644 --- a/api/src/Sozsoft.Platform.Application/Identity/PlatformIdentityAppService.cs +++ b/api/src/Sozsoft.Platform.Application/Identity/PlatformIdentityAppService.cs @@ -118,7 +118,7 @@ public class PlatformIdentityAppService : ApplicationService userRoleNames = userRoleNames, LockoutEnabled = user.LockoutEnabled, LockoutEnd = user.LockoutEnd, - LockUser = user.LockoutEnabled && user.LockoutEnd.HasValue && user.LockoutEnd.Value.DateTime > DateTime.UtcNow, + LockUser = user.LockoutEnabled && user.LockoutEnd.HasValue && user.LockoutEnd.Value.DateTime > DateTime.Now, LoginEndDate = user.GetLoginEndDate(), ConcurrencyStamp = user.ConcurrencyStamp, LastPasswordChangeTime = user.LastPasswordChangeTime, @@ -140,7 +140,7 @@ public class PlatformIdentityAppService : ApplicationService if (UserInfo.LockUser) { await UserManager.SetLockoutEnabledAsync(user, true); - await UserManager.SetLockoutEndDateAsync(user, DateTime.UtcNow.AddYears(1000)); + await UserManager.SetLockoutEndDateAsync(user, DateTime.Now.AddYears(1000)); } else { diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/HostData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/HostData.json index 4793328..905e267 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/HostData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/HostData.json @@ -1055,10 +1055,17 @@ "BackgroundWorkers": [ { "name": "Notification Worker", - "cron": "5 * * * *", + "cron": "*/5 * * * *", "workerType": "NotificationWorker", "isActive": true, "dataSourceCode": "Default" + }, + { + "name": "Session Cleanup Worker", + "cron": "*/5 * * * *", + "workerType": "SessionCleanupWorker", + "isActive": true, + "dataSourceCode": "Default" } ], "ContactTitles": [ diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 3fe3c92..74a542b 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -3117,8 +3117,8 @@ { "resourceName": "Platform", "key": "Abp.Identity.ConcurrentUserLimitError", - "en": "The maximum number of simultaneous users has been reached. Please try again later.", - "tr": "Eş zamanlı kullanıcı limiti dolmuştur. Lütfen daha sonra tekrar deneyiniz." + "en": "The maximum number of simultaneous users has been reached.", + "tr": "Eş zamanlı kullanıcı limiti dolmuştur." }, { "resourceName": "Platform", @@ -13682,6 +13682,12 @@ "en": "Is Active", "tr": "Aktif" }, + { + "resourceName": "Platform", + "key": "App.Listform.ListformField.MaxConcurrentUsers", + "en": "Max Concurrent Users", + "tr": "Maksimum Eşzamanlı Kullanıcı" + }, { "resourceName": "Platform", "key": "App.Listform.ListformField.IsVerified", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs index 868eeac..f639fa7 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs @@ -104,6 +104,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency new EditingFormItemDto { Order=7, DataField = "PhoneNumber", ColSpan=1, IsRequired=false, EditorType2=EditorTypes.dxTextBox, EditorOptions=EditorOptionValues.PhoneEditorOptions }, new EditingFormItemDto { Order=8, DataField = "FaxNumber", ColSpan=1, IsRequired=false, EditorType2=EditorTypes.dxTextBox, EditorOptions=EditorOptionValues.PhoneEditorOptions }, new EditingFormItemDto { Order=9, DataField = "IsActive", ColSpan=1, IsRequired=false, EditorType2=EditorTypes.dxCheckBox }, + new EditingFormItemDto { Order=10, DataField = "MaxConcurrentUsers", ColSpan=1, IsRequired=false, EditorType2=EditorTypes.dxNumberBox }, ] }, new() { Order=2, ColCount=2, ColSpan=1, ItemType="group", Items = @@ -182,7 +183,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "Name", - CaptionName = "App.Listform.ListformField.Name", + CaptionName = "App.Listform.ListformField.Name", Width = 100, ListOrderNo = 2, Visible = true, @@ -199,7 +200,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "OrganizationName", - CaptionName = "App.Listform.ListformField.OrganizationName", + CaptionName = "App.Listform.ListformField.OrganizationName", Width = 200, ListOrderNo = 3, Visible = true, @@ -216,7 +217,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "Founder", - CaptionName = "App.Listform.ListformField.Founder", + CaptionName = "App.Listform.ListformField.Founder", Width = 200, ListOrderNo = 4, Visible = true, @@ -233,7 +234,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.Int64, FieldName = "VknTckn", - CaptionName = "App.Listform.ListformField.VknTckn", + CaptionName = "App.Listform.ListformField.VknTckn", Width = 100, ListOrderNo = 5, Visible = true, @@ -250,7 +251,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "TaxOffice", - CaptionName = "App.Listform.ListformField.TaxOffice", + CaptionName = "App.Listform.ListformField.TaxOffice", Width = 150, ListOrderNo = 6, Visible = true, @@ -267,7 +268,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "Country", - CaptionName = "App.Listform.ListformField.Country", + CaptionName = "App.Listform.ListformField.Country", Width = 100, ListOrderNo = 7, Visible = true, @@ -292,7 +293,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "City", - CaptionName = "App.Listform.ListformField.City", + CaptionName = "App.Listform.ListformField.City", Width = 100, ListOrderNo = 8, Visible = true, @@ -320,7 +321,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "District", - CaptionName = "App.Listform.ListformField.District", + CaptionName = "App.Listform.ListformField.District", Width = 100, ListOrderNo = 9, Visible = true, @@ -348,7 +349,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "Township", - CaptionName = "App.Listform.ListformField.Township", + CaptionName = "App.Listform.ListformField.Township", Width = 100, ListOrderNo = 10, Visible = true, @@ -375,7 +376,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "Address1", - CaptionName = "App.Listform.ListformField.Address1", + CaptionName = "App.Listform.ListformField.Address1", Width = 150, ListOrderNo = 11, Visible = true, @@ -392,7 +393,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "Address2", - CaptionName = "App.Listform.ListformField.Address2", + CaptionName = "App.Listform.ListformField.Address2", Width = 150, ListOrderNo = 12, Visible = true, @@ -409,7 +410,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "PostalCode", - CaptionName = "App.Listform.ListformField.PostalCode", + CaptionName = "App.Listform.ListformField.PostalCode", Width = 100, ListOrderNo = 13, Visible = true, @@ -426,7 +427,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "Email", - CaptionName = "Abp.Account.EmailAddress", + CaptionName = "App.Listform.ListformField.Email", Width = 170, ListOrderNo = 14, Visible = true, @@ -444,7 +445,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "Website", - CaptionName = "App.Listform.ListformField.Website", + CaptionName = "App.Listform.ListformField.Website", Width = 170, ListOrderNo = 15, Visible = true, @@ -479,7 +480,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "PhoneNumber", - CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber", + CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber", Width = 100, ListOrderNo = 17, Visible = true, @@ -497,7 +498,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.Int64, FieldName = "FaxNumber", - CaptionName = "App.Listform.ListformField.FaxNumber", + CaptionName = "App.Listform.ListformField.FaxNumber", Width = 100, ListOrderNo = 18, Visible = true, @@ -515,7 +516,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.Boolean, FieldName = "IsActive", - CaptionName = "App.Listform.ListformField.IsActive", + CaptionName = "App.Listform.ListformField.IsActive", Width = 100, ListOrderNo = 19, Visible = true, @@ -531,7 +532,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency CultureName = LanguageCodes.En, SourceDbType = DbType.String, FieldName = "MenuGroup", - CaptionName = "App.Listform.ListformField.MenuGroup", + CaptionName = "App.Listform.ListformField.MenuGroup", Width = 100, ListOrderNo = 20, Visible = true, @@ -547,6 +548,22 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency PermissionJson = DefaultFieldPermissionJson(TenantManagementPermissions.Tenants.Create, TenantManagementPermissions.Tenants.Default, TenantManagementPermissions.Tenants.Update, true, true, false), PivotSettingsJson = DefaultPivotSettingsJson }, + new ListFormField + { + ListFormCode = listForm.ListFormCode, + CultureName = LanguageCodes.En, + SourceDbType = DbType.Int32, + FieldName = "MaxConcurrentUsers", + CaptionName = "App.Listform.ListformField.MaxConcurrentUsers", + Width = 100, + ListOrderNo = 21, + Visible = true, + IsActive = true, + IsDeleted = false, + ColumnCustomizationJson = DefaultColumnCustomizationJson, + PermissionJson = DefaultFieldPermissionJson(TenantManagementPermissions.Tenants.Create, TenantManagementPermissions.Tenants.Default, TenantManagementPermissions.Tenants.Update, true, true, false), + PivotSettingsJson = DefaultPivotSettingsJson + }, ], autoSave: true); #endregion } @@ -4814,6 +4831,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency new() { Key=1,Name="MailQueueWorker" }, new() { Key=2,Name="SqlWorker" }, new() { Key=3,Name="NotificationWorker" }, + new() { Key=4,Name="SessionCleanupWorker" }, }), }), ValidationRuleJson = DefaultValidationRuleRequiredJson, diff --git a/api/src/Sozsoft.Platform.Domain.Shared/Enums/WorkerTypeEnum.cs b/api/src/Sozsoft.Platform.Domain.Shared/Enums/WorkerTypeEnum.cs index ed899f6..3fd9cb9 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/Enums/WorkerTypeEnum.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/Enums/WorkerTypeEnum.cs @@ -5,5 +5,6 @@ public enum WorkerTypeEnum MailQueueWorker = 1, SqlWorker = 2, NotificationWorker = 3, + SessionCleanupWorker = 4, } diff --git a/api/src/Sozsoft.Platform.Domain/BackgroundWorkers/ISessionCleanupWorker.cs b/api/src/Sozsoft.Platform.Domain/BackgroundWorkers/ISessionCleanupWorker.cs new file mode 100644 index 0000000..5669677 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/BackgroundWorkers/ISessionCleanupWorker.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Sozsoft.Platform.BackgroundWorkers; + +public interface ISessionCleanupWorker +{ + Task StartAsync(CancellationToken cancellationToken = default); +} diff --git a/api/src/Sozsoft.Platform.Domain/BackgroundWorkers/PlatformBackgroundWorker.cs b/api/src/Sozsoft.Platform.Domain/BackgroundWorkers/PlatformBackgroundWorker.cs index 4ffa903..c279185 100644 --- a/api/src/Sozsoft.Platform.Domain/BackgroundWorkers/PlatformBackgroundWorker.cs +++ b/api/src/Sozsoft.Platform.Domain/BackgroundWorkers/PlatformBackgroundWorker.cs @@ -95,6 +95,10 @@ public class PlatformBackgroundWorker : PlatformDomainService, IPlatformBackgrou { await LazyServiceProvider.GetRequiredService().StartAsync(cancellationToken); } + else if (Worker.WorkerType == WorkerTypeEnum.SessionCleanupWorker) + { + await LazyServiceProvider.GetRequiredService().StartAsync(cancellationToken); + } // Call AfterSp if (!Worker.AfterSp.IsNullOrWhiteSpace()) diff --git a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/DeveloperKit/DynamicService.cs b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/DeveloperKit/DynamicService.cs index ded4be7..00dcfa3 100644 --- a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/DeveloperKit/DynamicService.cs +++ b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/DeveloperKit/DynamicService.cs @@ -127,7 +127,7 @@ public class DynamicService : FullAuditedEntity, IMultiTenant { CompilationStatus = CompilationStatus.Success; LastCompilationError = null; - LastSuccessfulCompilation = DateTime.UtcNow; + LastSuccessfulCompilation = DateTime.Now; } /// diff --git a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/Website/BlogPost.cs b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/Website/BlogPost.cs index d7afb9c..42f9d78 100644 --- a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/Website/BlogPost.cs +++ b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/Website/BlogPost.cs @@ -28,7 +28,7 @@ public class BlogPost : FullAuditedEntity public void Publish() { IsPublished = true; - PublishedAt = DateTime.UtcNow; + PublishedAt = DateTime.Now; } public void Unpublish() diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantDataSeeder.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantDataSeeder.cs index 44f5701..7165686 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantDataSeeder.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantDataSeeder.cs @@ -684,7 +684,7 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency CategoryId = category.Id, Author = item.Author, IsPublished = true, - PublishedAt = DateTime.UtcNow + PublishedAt = DateTime.Now }); } } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/DynamicServices/DynamicAssemblyRegistrationService.cs b/api/src/Sozsoft.Platform.HttpApi.Host/DynamicServices/DynamicAssemblyRegistrationService.cs index f1cd51f..9c59d74 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/DynamicServices/DynamicAssemblyRegistrationService.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/DynamicServices/DynamicAssemblyRegistrationService.cs @@ -57,7 +57,7 @@ namespace Sozsoft.Platform.DynamicServices TenantId = tenantId, Assembly = assembly, AssemblyName = assemblyName, - RequestTime = DateTime.UtcNow + RequestTime = DateTime.Now }); } } @@ -73,7 +73,7 @@ namespace Sozsoft.Platform.DynamicServices { TenantId = tenantId, ServiceName = serviceName, - RequestTime = DateTime.UtcNow + RequestTime = DateTime.Now }); } } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionCleanupWorker.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionCleanupWorker.cs new file mode 100644 index 0000000..1948c58 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionCleanupWorker.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Sozsoft.Platform.BackgroundWorkers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenIddict.Server; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Identity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TenantManagement; +using Volo.Abp.Uow; +using Sozsoft.Platform.Extensions; + +namespace Sozsoft.Platform.Identity; + +/// +/// AbpSessions tablosundaki atıl (idle) oturumları temizleyen Hangfire job'ı. +/// Hem Hangfire (DoWorkAsync) hem de PlatformBackgroundWorker altyapısından (ISessionCleanupWorker) çağrılabilir. +/// Atıl sayılma eşiği: OpenIddict refresh token ömrü + 5 dakika. +/// +public class PlatformSessionCleanupWorker : ISessionCleanupWorker, ITransientDependency +{ + private readonly IIdentitySessionRepository sessionRepo; + private readonly ITenantRepository tenantRepository; + private readonly ICurrentTenant currentTenant; + private readonly ILogger logger; + private readonly IOptions openIddictOptions; + + public PlatformSessionCleanupWorker( + IIdentitySessionRepository sessionRepo, + ITenantRepository tenantRepository, + ICurrentTenant currentTenant, + ILogger logger, + IOptions openIddictOptions) + { + this.sessionRepo = sessionRepo; + this.tenantRepository = tenantRepository; + this.currentTenant = currentTenant; + this.logger = logger; + this.openIddictOptions = openIddictOptions; + } + + [UnitOfWork] + public virtual async Task StartAsync(CancellationToken cancellationToken = default) + { + var refreshTokenLifetime = openIddictOptions.Value.RefreshTokenLifetime ?? TimeSpan.FromMinutes(90); + var cutoff = DateTime.Now - refreshTokenLifetime - TimeSpan.FromMinutes(5); + + try + { + var activeTenants = (await tenantRepository.GetListAsync(cancellationToken: cancellationToken)) + .Where(t => t.GetIsActive()) + .ToList(); + + logger.LogDebug( + "IdentitySession temizliği: host + {Count} aktif tenant işlenecek (eşik: {Cutoff:s}).", + activeTenants.Count, cutoff); + + var totalDeleted = 0; + + // Host (null) + tüm aktif tenant'lar + var scopes = Enumerable.Repeat(null, 1) + .Concat(activeTenants.Select(t => (Guid?)t.Id)); + + foreach (var tenantId in scopes) + { + using (currentTenant.Change(tenantId)) + { + var sessions = await sessionRepo.GetListAsync(cancellationToken: cancellationToken); + var staleSessions = sessions + .Where(s => (s.LastAccessed ?? s.SignedIn) < cutoff) + .ToList(); + + foreach (var session in staleSessions) + { + await sessionRepo.DeleteAsync(session, cancellationToken: cancellationToken); + } + + totalDeleted += staleSessions.Count; + + if (staleSessions.Count > 0) + { + logger.LogInformation( + "IdentitySession temizliği: {Scope} için {Deleted} atıl oturum silindi.", + tenantId.HasValue ? $"Tenant [{tenantId}]" : "Host", staleSessions.Count); + } + } + } + + if (totalDeleted > 0) + { + logger.LogInformation( + "IdentitySession temizliği tamamlandı: toplam {Deleted} oturum silindi.", + totalDeleted); + } + } + catch (Exception ex) + { + logger.LogError(ex, "IdentitySession temizliği sırasında hata oluştu."); + } + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionRevocationHandler.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionRevocationHandler.cs new file mode 100644 index 0000000..07c6e66 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionRevocationHandler.cs @@ -0,0 +1,87 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Server.OpenIddictServerEvents; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Identity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; + +namespace Sozsoft.Platform.Identity; + +/// +/// /connect/revocation çağrısı başarıyla tamamlandığında ilgili kullanıcının +/// AbpSessions tablosundaki tüm satırlarını siler. +/// OpenIddict server pipeline'ına eklenir; MVC controller override gerektirmez. +/// +public class PlatformSessionRevocationHandler : + IOpenIddictServerHandler, + ITransientDependency +{ + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor + .CreateBuilder() + .UseScopedHandler() + .SetOrder(int.MaxValue - 10) + .SetType(OpenIddictServerHandlerType.Custom) + .Build(); + + private readonly IIdentitySessionRepository sessionRepo; + private readonly ICurrentTenant currentTenant; + private readonly ILogger logger; + + public PlatformSessionRevocationHandler( + IIdentitySessionRepository sessionRepo, + ICurrentTenant currentTenant, + ILogger logger) + { + this.sessionRepo = sessionRepo; + this.currentTenant = currentTenant; + this.logger = logger; + } + + public async ValueTask HandleAsync(HandleRevocationRequestContext context) + { + // Hata durumunda (geçersiz token, yetkisiz istek vb.) temizlik yapma. + if (context.IsRejected) return; + + var userId = context.Principal?.GetClaim(OpenIddictConstants.Claims.Subject); + if (string.IsNullOrEmpty(userId) || !Guid.TryParse(userId, out var userGuid)) return; + + // Token'dan tenant ID'yi al. + var tenantIdStr = context.Principal?.GetClaim(AbpClaimTypes.TenantId); + Guid? tenantId = null; + if (!string.IsNullOrWhiteSpace(tenantIdStr) && Guid.TryParse(tenantIdStr, out var parsedTenantId)) + { + tenantId = parsedTenantId; + } + + try + { + // Sadece token'ın ait olduğu tenant'taki oturumları sil. + using (currentTenant.Change(tenantId)) + { + var sessions = await sessionRepo.GetListAsync(userId: userGuid); + foreach (var session in sessions) + { + await sessionRepo.DeleteAsync(session); + } + + if (sessions.Count > 0) + { + logger.LogInformation( + "Token revocation: {Count} IdentitySession silindi. UserId: {UserId}, TenantId: {TenantId}", + sessions.Count, userGuid, tenantId); + } + } + } + catch (Exception ex) + { + // Session temizleme hatası revocation akışını bloklamamalı. + logger.LogWarning(ex, + "Token revocation sonrası IdentitySession temizliğinde hata. UserId: {UserId}", userId); + } + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSignInManager.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSignInManager.cs index 2102539..4269776 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSignInManager.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSignInManager.cs @@ -13,18 +13,19 @@ using Microsoft.Extensions.Options; using Volo.Abp.Domain.Repositories; using Volo.Abp.Identity; using Volo.Abp.Identity.AspNetCore; +using Volo.Abp.MultiTenancy; using Volo.Abp.Settings; using Volo.Abp.TenantManagement; using Volo.Abp.Timing; using IdentityUser = Volo.Abp.Identity.IdentityUser; using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; -using Microsoft.EntityFrameworkCore; namespace Sozsoft.Platform.Identity; public interface IPlatformSignInManager { Task PreSignInCheckAsync(IdentityUser user); + Task CheckConcurrentLimitAsync(IdentityUser user); } public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager @@ -35,6 +36,7 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager private readonly ITenantRepository tenantRepository; private readonly IdentityUserManager userManager; private readonly IIdentitySessionRepository identitySessionRepository; + private readonly ICurrentTenant currentTenant; public PlatformSignInManager( IdentityUserManager userManager, @@ -50,7 +52,8 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager IRepository repositoryIp, IRepository repositoryWorkHour, ITenantRepository tenantRepository, - IIdentitySessionRepository identitySessionRepository + IIdentitySessionRepository identitySessionRepository, + ICurrentTenant currentTenant ) : base( userManager, contextAccessor, @@ -68,6 +71,7 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager this.tenantRepository = tenantRepository; this.userManager = userManager; this.identitySessionRepository = identitySessionRepository; + this.currentTenant = currentTenant; } public async Task PreSignInCheckAsync(IdentityUser user) @@ -102,7 +106,7 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager { return new PlatformSignInResult() { IsNotAllowed_WorkHour = true }; } - if (!await CanSignInConcurrentLimitAsync(user)) + if (!await CheckConcurrentLimitAsync(user)) { return new PlatformSignInResult() { IsNotAllowed_ConcurrentUserLimit = true }; } @@ -265,11 +269,11 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager } /// - /// Prevents login when the tenant's concurrent user limit is reached. - /// Counts distinct active users (by UserId) in AbpSessions for the tenant. - /// A user who already has an active session is not counted as a new concurrent user. + /// Tenant'a tanımlı MaxConcurrentUsers limitini aşmamak için login'i engeller. + /// AbpSessions tablosundaki aktif oturumları sayar (farklı UserId'lere göre distinct). + /// Kullanıcının kendisinin zaten oturumu varsa (refresh senaryosu) yeni concurrent user sayılmaz. /// - private async Task CanSignInConcurrentLimitAsync(IdentityUser user) + public async Task CheckConcurrentLimitAsync(IdentityUser user) { if (!user.TenantId.HasValue) { @@ -283,24 +287,32 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager } var maxConcurrentUsers = tenant.GetMaxConcurrentUsers(); - if (!maxConcurrentUsers.HasValue || maxConcurrentUsers.Value <= 0) + if (!maxConcurrentUsers.HasValue || maxConcurrentUsers.Value == 0) { return true; } - var sessions = await identitySessionRepository.GetListAsync(); - var activeUserCount = sessions - .Where(s => s.UserId != user.Id) - .Select(s => s.UserId) - .Distinct() - .Count(); - - if (activeUserCount >= maxConcurrentUsers.Value) + // Tenant bağlamını explicit olarak set et — hem password hem refresh token akışlarında + // doğru tenant izolasyonu için güvenli bir şekilde değiştirilir. + using (currentTenant.Change(user.TenantId)) { - Logger.LogWarning(PlatformEventIds.UserCannotSignInConcurrentUserLimit, - "Tenant {TenantId} concurrent user limit of {Limit} reached. Active users: {ActiveCount}.", - user.TenantId, maxConcurrentUsers.Value, activeUserCount); - return false; + var sessions = await identitySessionRepository.GetListAsync(); + + // Bu kullanıcı hariç diğer distinct aktif kullanıcı sayısını hesapla. + // Kullanıcının kendi oturumu varsa (refresh) o kişi tekrar sayılmaz. + var otherActiveUserCount = sessions + .Where(s => s.UserId != user.Id) + .Select(s => s.UserId) + .Distinct() + .Count(); + + if (otherActiveUserCount >= maxConcurrentUsers.Value) + { + Logger.LogWarning(PlatformEventIds.UserCannotSignInConcurrentUserLimit, + "Tenant {TenantId} concurrent user limit of {Limit} reached. Active users: {ActiveCount}.", + user.TenantId, maxConcurrentUsers.Value, otherActiveUserCount); + return false; + } } return true; diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformTokenController.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformTokenController.cs index 297b489..6a7edb6 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformTokenController.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformTokenController.cs @@ -2,20 +2,25 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Sozsoft.Platform.Extensions; using Sozsoft.Platform.Localization; using Sozsoft.Sender.Mail; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using Volo.Abp; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.DependencyInjection; using Volo.Abp.Identity; using Volo.Abp.OpenIddict; using Volo.Abp.OpenIddict.Controllers; +using Volo.Abp.Security.Claims; using Volo.Abp.Settings; using Volo.Abp.Uow; using Volo.Abp.Validation; @@ -31,17 +36,20 @@ public class PlatformTokenController : TokenController private readonly ISozsoftEmailSender emailSender; private readonly ICaptchaManager captchaManager; private readonly IPlatformSignInManager platformSignInManager; + private readonly IIdentitySessionRepository identitySessionRepository; private IStringLocalizer LP; public PlatformTokenController( ISozsoftEmailSender emailSender, ICaptchaManager captchaManager, IPlatformSignInManager platformSignInManager, + IIdentitySessionRepository identitySessionRepository, IStringLocalizer LP) : base() { this.emailSender = emailSender; this.captchaManager = captchaManager; this.platformSignInManager = platformSignInManager; + this.identitySessionRepository = identitySessionRepository; this.LP = LP; } @@ -224,6 +232,117 @@ Your login code: {twoFactorToken}"; return true; } + + protected override async Task SetSuccessResultAsync(OpenIddictRequest request, IdentityUser user) + { + var result = await base.SetSuccessResultAsync(request, user); + await ManageIdentitySessionAsync(user, request); + return result; + } + + private async Task ManageIdentitySessionAsync(IdentityUser user, OpenIddictRequest request) + { + // Refresh grant session yönetimi HandleRefreshTokenAsync'da yapılır. + if (request.IsRefreshTokenGrantType()) return; + + try + { + using (CurrentTenant.Change(user.TenantId)) + { + var existingSessions = await identitySessionRepository.GetListAsync(userId: user.Id); + if (existingSessions.Any()) + { + foreach (var session in existingSessions) + await identitySessionRepository.DeleteAsync(session); + } + else + { + if (!await platformSignInManager.CheckConcurrentLimitAsync(user)) + throw new UserFriendlyException(PlatformConsts.UserCannotSignInErrors.LoginNotAllowed_ConcurrentUserLimit); + } + + await InsertSessionAsync(user, request.ClientId); + } + } + catch (UserFriendlyException) { throw; } + catch (Exception ex) + { + Logger.LogWarning(ex, "IdentitySession yönetiminde hata oluştu. UserId: {UserId}", user.Id); + } + } + + [UnitOfWork] + protected override async Task HandleRefreshTokenAsync(OpenIddictRequest request) + { + var info = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + var tenantId = ParseTenantId(info.Principal?.Claims.FirstOrDefault(c => c.Type == AbpClaimTypes.TenantId)?.Value); + + IdentityUser refreshUser = null; + var userIdStr = info.Principal?.GetClaim(OpenIddictConstants.Claims.Subject); + if (!userIdStr.IsNullOrWhiteSpace() && Guid.TryParse(userIdStr, out var userId)) + { + using (CurrentTenant.Change(tenantId)) + { + refreshUser = await UserManager.FindByIdAsync(userId.ToString()); + if (refreshUser != null) + { + if (!await platformSignInManager.CheckConcurrentLimitAsync(refreshUser)) + throw new UserFriendlyException(PlatformConsts.UserCannotSignInErrors.LoginNotAllowed_ConcurrentUserLimit); + + var existingSessions = await identitySessionRepository.GetListAsync(userId: userId); + foreach (var session in existingSessions) + await identitySessionRepository.DeleteAsync(session); + } + } + } + + IActionResult result; + using (CurrentTenant.Change(tenantId)) + { + result = await base.HandleRefreshTokenAsync(request); + } + + if (result is Microsoft.AspNetCore.Mvc.SignInResult && refreshUser != null) + { + try + { + using (CurrentTenant.Change(refreshUser.TenantId)) + { + await InsertSessionAsync(refreshUser, request.ClientId); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Refresh token sonrası IdentitySession oluşturulamadı. UserId: {UserId}", refreshUser.Id); + } + } + + return result; + } + + private async Task InsertSessionAsync(IdentityUser user, string clientId) + { + var userAgent = HttpContext?.Request?.Headers["User-Agent"].ToString(); + var deviceInfo = userAgent?.Length > 64 ? userAgent[..64] : userAgent; + + var session = new IdentitySession( + GuidGenerator.Create(), + Guid.NewGuid().ToString("N"), + "Web", + deviceInfo, + user.Id, + user.TenantId, + clientId ?? "platform", + HttpContext?.Connection?.RemoteIpAddress?.ToString(), + Clock.Now + ); + + await identitySessionRepository.InsertAsync(session); + } + + private static Guid? ParseTenantId(string value) => + !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out var id) ? id : null; }