ConcurrentUser Çalışması

This commit is contained in:
Sedat Öztürk 2026-04-28 00:29:03 +03:00
parent 70fb9ae499
commit ce3028ce56
17 changed files with 424 additions and 57 deletions

View file

@ -28,7 +28,7 @@ public class MailQueueWorker : BackgroundWorkerBase
public IGuidGenerator GuidGenerator { get; } public IGuidGenerator GuidGenerator { get; }
public MailQueueWorker( public MailQueueWorker(
ISozsoftEmailSender ErpEmailSender, ISozsoftEmailSender erpEmailSender,
IRepository<Entities.BackgroundWorker_MailQueue> repository, IRepository<Entities.BackgroundWorker_MailQueue> repository,
IClock clock, IClock clock,
IConfiguration configuration, IConfiguration configuration,
@ -39,7 +39,7 @@ public class MailQueueWorker : BackgroundWorkerBase
IGuidGenerator guidGenerator IGuidGenerator guidGenerator
) )
{ {
ErpEmailSender = ErpEmailSender; ErpEmailSender = erpEmailSender;
Repository = repository; Repository = repository;
Clock = clock; Clock = clock;
Configuration = configuration; Configuration = configuration;

View file

@ -338,8 +338,8 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
Id = EncodePathAsId(newFolderPath), Id = EncodePathAsId(newFolderPath),
Name = input.Name, Name = input.Name,
Type = "folder", Type = "folder",
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.Now,
ModifiedAt = DateTime.UtcNow, ModifiedAt = DateTime.Now,
Path = newFolderPath, Path = newFolderPath,
ParentId = decodedParentId ?? string.Empty, ParentId = decodedParentId ?? string.Empty,
IsReadOnly = false, IsReadOnly = false,
@ -430,8 +430,8 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
Size = fileSize, Size = fileSize,
Extension = normalizedExtension, Extension = normalizedExtension,
MimeType = GetMimeType(normalizedExtension), MimeType = GetMimeType(normalizedExtension),
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.Now,
ModifiedAt = DateTime.UtcNow, ModifiedAt = DateTime.Now,
Path = filePath, Path = filePath,
ParentId = decodedParentId ?? string.Empty, ParentId = decodedParentId ?? string.Empty,
IsReadOnly = false, IsReadOnly = false,
@ -500,13 +500,13 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
// Update metadata // Update metadata
metadata.Name = input.Name; metadata.Name = input.Name;
metadata.Path = newPath; metadata.Path = newPath;
metadata.ModifiedAt = DateTime.UtcNow; metadata.ModifiedAt = DateTime.Now;
// Update parent index // Update parent index
var itemToUpdate = parentItems.First(x => x.Id == id); var itemToUpdate = parentItems.First(x => x.Id == id);
itemToUpdate.Name = input.Name; itemToUpdate.Name = input.Name;
itemToUpdate.Path = newPath; itemToUpdate.Path = newPath;
itemToUpdate.ModifiedAt = DateTime.UtcNow; itemToUpdate.ModifiedAt = DateTime.Now;
await SaveFolderIndexAsync(parentItems, tenantId, metadata.ParentId == string.Empty ? null : metadata.ParentId); await SaveFolderIndexAsync(parentItems, tenantId, metadata.ParentId == string.Empty ? null : metadata.ParentId);
@ -570,7 +570,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
// Update metadata // Update metadata
metadata.Path = newPath; metadata.Path = newPath;
metadata.ParentId = input.TargetFolderId ?? string.Empty; metadata.ParentId = input.TargetFolderId ?? string.Empty;
metadata.ModifiedAt = DateTime.UtcNow; metadata.ModifiedAt = DateTime.Now;
// Add to target // Add to target
targetItems.Add(metadata); targetItems.Add(metadata);

View file

@ -118,7 +118,7 @@ public class PlatformIdentityAppService : ApplicationService
userRoleNames = userRoleNames, userRoleNames = userRoleNames,
LockoutEnabled = user.LockoutEnabled, LockoutEnabled = user.LockoutEnabled,
LockoutEnd = user.LockoutEnd, 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(), LoginEndDate = user.GetLoginEndDate(),
ConcurrencyStamp = user.ConcurrencyStamp, ConcurrencyStamp = user.ConcurrencyStamp,
LastPasswordChangeTime = user.LastPasswordChangeTime, LastPasswordChangeTime = user.LastPasswordChangeTime,
@ -140,7 +140,7 @@ public class PlatformIdentityAppService : ApplicationService
if (UserInfo.LockUser) if (UserInfo.LockUser)
{ {
await UserManager.SetLockoutEnabledAsync(user, true); await UserManager.SetLockoutEnabledAsync(user, true);
await UserManager.SetLockoutEndDateAsync(user, DateTime.UtcNow.AddYears(1000)); await UserManager.SetLockoutEndDateAsync(user, DateTime.Now.AddYears(1000));
} }
else else
{ {

View file

@ -1055,10 +1055,17 @@
"BackgroundWorkers": [ "BackgroundWorkers": [
{ {
"name": "Notification Worker", "name": "Notification Worker",
"cron": "5 * * * *", "cron": "*/5 * * * *",
"workerType": "NotificationWorker", "workerType": "NotificationWorker",
"isActive": true, "isActive": true,
"dataSourceCode": "Default" "dataSourceCode": "Default"
},
{
"name": "Session Cleanup Worker",
"cron": "*/5 * * * *",
"workerType": "SessionCleanupWorker",
"isActive": true,
"dataSourceCode": "Default"
} }
], ],
"ContactTitles": [ "ContactTitles": [

View file

@ -3117,8 +3117,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "Abp.Identity.ConcurrentUserLimitError", "key": "Abp.Identity.ConcurrentUserLimitError",
"en": "The maximum number of simultaneous users has been reached. Please try again later.", "en": "The maximum number of simultaneous users has been reached.",
"tr": "Eş zamanlı kullanıcı limiti dolmuştur. Lütfen daha sonra tekrar deneyiniz." "tr": "Eş zamanlı kullanıcı limiti dolmuştur."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -13682,6 +13682,12 @@
"en": "Is Active", "en": "Is Active",
"tr": "Aktif" "tr": "Aktif"
}, },
{
"resourceName": "Platform",
"key": "App.Listform.ListformField.MaxConcurrentUsers",
"en": "Max Concurrent Users",
"tr": "Maksimum Eşzamanlı Kullanıcı"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Listform.ListformField.IsVerified", "key": "App.Listform.ListformField.IsVerified",

View file

@ -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=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=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=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 = new() { Order=2, ColCount=2, ColSpan=1, ItemType="group", Items =
@ -182,7 +183,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Name", FieldName = "Name",
CaptionName = "App.Listform.ListformField.Name", CaptionName = "App.Listform.ListformField.Name",
Width = 100, Width = 100,
ListOrderNo = 2, ListOrderNo = 2,
Visible = true, Visible = true,
@ -199,7 +200,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "OrganizationName", FieldName = "OrganizationName",
CaptionName = "App.Listform.ListformField.OrganizationName", CaptionName = "App.Listform.ListformField.OrganizationName",
Width = 200, Width = 200,
ListOrderNo = 3, ListOrderNo = 3,
Visible = true, Visible = true,
@ -216,7 +217,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Founder", FieldName = "Founder",
CaptionName = "App.Listform.ListformField.Founder", CaptionName = "App.Listform.ListformField.Founder",
Width = 200, Width = 200,
ListOrderNo = 4, ListOrderNo = 4,
Visible = true, Visible = true,
@ -233,7 +234,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.Int64, SourceDbType = DbType.Int64,
FieldName = "VknTckn", FieldName = "VknTckn",
CaptionName = "App.Listform.ListformField.VknTckn", CaptionName = "App.Listform.ListformField.VknTckn",
Width = 100, Width = 100,
ListOrderNo = 5, ListOrderNo = 5,
Visible = true, Visible = true,
@ -250,7 +251,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "TaxOffice", FieldName = "TaxOffice",
CaptionName = "App.Listform.ListformField.TaxOffice", CaptionName = "App.Listform.ListformField.TaxOffice",
Width = 150, Width = 150,
ListOrderNo = 6, ListOrderNo = 6,
Visible = true, Visible = true,
@ -267,7 +268,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Country", FieldName = "Country",
CaptionName = "App.Listform.ListformField.Country", CaptionName = "App.Listform.ListformField.Country",
Width = 100, Width = 100,
ListOrderNo = 7, ListOrderNo = 7,
Visible = true, Visible = true,
@ -292,7 +293,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "City", FieldName = "City",
CaptionName = "App.Listform.ListformField.City", CaptionName = "App.Listform.ListformField.City",
Width = 100, Width = 100,
ListOrderNo = 8, ListOrderNo = 8,
Visible = true, Visible = true,
@ -320,7 +321,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "District", FieldName = "District",
CaptionName = "App.Listform.ListformField.District", CaptionName = "App.Listform.ListformField.District",
Width = 100, Width = 100,
ListOrderNo = 9, ListOrderNo = 9,
Visible = true, Visible = true,
@ -348,7 +349,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Township", FieldName = "Township",
CaptionName = "App.Listform.ListformField.Township", CaptionName = "App.Listform.ListformField.Township",
Width = 100, Width = 100,
ListOrderNo = 10, ListOrderNo = 10,
Visible = true, Visible = true,
@ -375,7 +376,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Address1", FieldName = "Address1",
CaptionName = "App.Listform.ListformField.Address1", CaptionName = "App.Listform.ListformField.Address1",
Width = 150, Width = 150,
ListOrderNo = 11, ListOrderNo = 11,
Visible = true, Visible = true,
@ -392,7 +393,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Address2", FieldName = "Address2",
CaptionName = "App.Listform.ListformField.Address2", CaptionName = "App.Listform.ListformField.Address2",
Width = 150, Width = 150,
ListOrderNo = 12, ListOrderNo = 12,
Visible = true, Visible = true,
@ -409,7 +410,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "PostalCode", FieldName = "PostalCode",
CaptionName = "App.Listform.ListformField.PostalCode", CaptionName = "App.Listform.ListformField.PostalCode",
Width = 100, Width = 100,
ListOrderNo = 13, ListOrderNo = 13,
Visible = true, Visible = true,
@ -426,7 +427,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Email", FieldName = "Email",
CaptionName = "Abp.Account.EmailAddress", CaptionName = "App.Listform.ListformField.Email",
Width = 170, Width = 170,
ListOrderNo = 14, ListOrderNo = 14,
Visible = true, Visible = true,
@ -444,7 +445,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "Website", FieldName = "Website",
CaptionName = "App.Listform.ListformField.Website", CaptionName = "App.Listform.ListformField.Website",
Width = 170, Width = 170,
ListOrderNo = 15, ListOrderNo = 15,
Visible = true, Visible = true,
@ -479,7 +480,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "PhoneNumber", FieldName = "PhoneNumber",
CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber", CaptionName = "Abp.Identity.User.UserInformation.PhoneNumber",
Width = 100, Width = 100,
ListOrderNo = 17, ListOrderNo = 17,
Visible = true, Visible = true,
@ -497,7 +498,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.Int64, SourceDbType = DbType.Int64,
FieldName = "FaxNumber", FieldName = "FaxNumber",
CaptionName = "App.Listform.ListformField.FaxNumber", CaptionName = "App.Listform.ListformField.FaxNumber",
Width = 100, Width = 100,
ListOrderNo = 18, ListOrderNo = 18,
Visible = true, Visible = true,
@ -515,7 +516,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.Boolean, SourceDbType = DbType.Boolean,
FieldName = "IsActive", FieldName = "IsActive",
CaptionName = "App.Listform.ListformField.IsActive", CaptionName = "App.Listform.ListformField.IsActive",
Width = 100, Width = 100,
ListOrderNo = 19, ListOrderNo = 19,
Visible = true, Visible = true,
@ -531,7 +532,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
CultureName = LanguageCodes.En, CultureName = LanguageCodes.En,
SourceDbType = DbType.String, SourceDbType = DbType.String,
FieldName = "MenuGroup", FieldName = "MenuGroup",
CaptionName = "App.Listform.ListformField.MenuGroup", CaptionName = "App.Listform.ListformField.MenuGroup",
Width = 100, Width = 100,
ListOrderNo = 20, ListOrderNo = 20,
Visible = true, 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), PermissionJson = DefaultFieldPermissionJson(TenantManagementPermissions.Tenants.Create, TenantManagementPermissions.Tenants.Default, TenantManagementPermissions.Tenants.Update, true, true, false),
PivotSettingsJson = DefaultPivotSettingsJson 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); ], autoSave: true);
#endregion #endregion
} }
@ -4814,6 +4831,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
new() { Key=1,Name="MailQueueWorker" }, new() { Key=1,Name="MailQueueWorker" },
new() { Key=2,Name="SqlWorker" }, new() { Key=2,Name="SqlWorker" },
new() { Key=3,Name="NotificationWorker" }, new() { Key=3,Name="NotificationWorker" },
new() { Key=4,Name="SessionCleanupWorker" },
}), }),
}), }),
ValidationRuleJson = DefaultValidationRuleRequiredJson, ValidationRuleJson = DefaultValidationRuleRequiredJson,

View file

@ -5,5 +5,6 @@ public enum WorkerTypeEnum
MailQueueWorker = 1, MailQueueWorker = 1,
SqlWorker = 2, SqlWorker = 2,
NotificationWorker = 3, NotificationWorker = 3,
SessionCleanupWorker = 4,
} }

View file

@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace Sozsoft.Platform.BackgroundWorkers;
public interface ISessionCleanupWorker
{
Task StartAsync(CancellationToken cancellationToken = default);
}

View file

@ -95,6 +95,10 @@ public class PlatformBackgroundWorker : PlatformDomainService, IPlatformBackgrou
{ {
await LazyServiceProvider.GetRequiredService<NotificationWorker>().StartAsync(cancellationToken); await LazyServiceProvider.GetRequiredService<NotificationWorker>().StartAsync(cancellationToken);
} }
else if (Worker.WorkerType == WorkerTypeEnum.SessionCleanupWorker)
{
await LazyServiceProvider.GetRequiredService<ISessionCleanupWorker>().StartAsync(cancellationToken);
}
// Call AfterSp // Call AfterSp
if (!Worker.AfterSp.IsNullOrWhiteSpace()) if (!Worker.AfterSp.IsNullOrWhiteSpace())

View file

@ -127,7 +127,7 @@ public class DynamicService : FullAuditedEntity<Guid>, IMultiTenant
{ {
CompilationStatus = CompilationStatus.Success; CompilationStatus = CompilationStatus.Success;
LastCompilationError = null; LastCompilationError = null;
LastSuccessfulCompilation = DateTime.UtcNow; LastSuccessfulCompilation = DateTime.Now;
} }
/// <summary> /// <summary>

View file

@ -28,7 +28,7 @@ public class BlogPost : FullAuditedEntity<Guid>
public void Publish() public void Publish()
{ {
IsPublished = true; IsPublished = true;
PublishedAt = DateTime.UtcNow; PublishedAt = DateTime.Now;
} }
public void Unpublish() public void Unpublish()

View file

@ -684,7 +684,7 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
CategoryId = category.Id, CategoryId = category.Id,
Author = item.Author, Author = item.Author,
IsPublished = true, IsPublished = true,
PublishedAt = DateTime.UtcNow PublishedAt = DateTime.Now
}); });
} }
} }

View file

@ -57,7 +57,7 @@ namespace Sozsoft.Platform.DynamicServices
TenantId = tenantId, TenantId = tenantId,
Assembly = assembly, Assembly = assembly,
AssemblyName = assemblyName, AssemblyName = assemblyName,
RequestTime = DateTime.UtcNow RequestTime = DateTime.Now
}); });
} }
} }
@ -73,7 +73,7 @@ namespace Sozsoft.Platform.DynamicServices
{ {
TenantId = tenantId, TenantId = tenantId,
ServiceName = serviceName, ServiceName = serviceName,
RequestTime = DateTime.UtcNow RequestTime = DateTime.Now
}); });
} }
} }

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public class PlatformSessionCleanupWorker : ISessionCleanupWorker, ITransientDependency
{
private readonly IIdentitySessionRepository sessionRepo;
private readonly ITenantRepository tenantRepository;
private readonly ICurrentTenant currentTenant;
private readonly ILogger<PlatformSessionCleanupWorker> logger;
private readonly IOptions<OpenIddictServerOptions> openIddictOptions;
public PlatformSessionCleanupWorker(
IIdentitySessionRepository sessionRepo,
ITenantRepository tenantRepository,
ICurrentTenant currentTenant,
ILogger<PlatformSessionCleanupWorker> logger,
IOptions<OpenIddictServerOptions> 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<Guid?>(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.");
}
}
}

View file

@ -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;
/// <summary>
/// /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.
/// </summary>
public class PlatformSessionRevocationHandler :
IOpenIddictServerHandler<HandleRevocationRequestContext>,
ITransientDependency
{
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor
.CreateBuilder<HandleRevocationRequestContext>()
.UseScopedHandler<PlatformSessionRevocationHandler>()
.SetOrder(int.MaxValue - 10)
.SetType(OpenIddictServerHandlerType.Custom)
.Build();
private readonly IIdentitySessionRepository sessionRepo;
private readonly ICurrentTenant currentTenant;
private readonly ILogger<PlatformSessionRevocationHandler> logger;
public PlatformSessionRevocationHandler(
IIdentitySessionRepository sessionRepo,
ICurrentTenant currentTenant,
ILogger<PlatformSessionRevocationHandler> 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);
}
}
}

View file

@ -13,18 +13,19 @@ using Microsoft.Extensions.Options;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Volo.Abp.Identity; using Volo.Abp.Identity;
using Volo.Abp.Identity.AspNetCore; using Volo.Abp.Identity.AspNetCore;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Settings; using Volo.Abp.Settings;
using Volo.Abp.TenantManagement; using Volo.Abp.TenantManagement;
using Volo.Abp.Timing; using Volo.Abp.Timing;
using IdentityUser = Volo.Abp.Identity.IdentityUser; using IdentityUser = Volo.Abp.Identity.IdentityUser;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
using Microsoft.EntityFrameworkCore;
namespace Sozsoft.Platform.Identity; namespace Sozsoft.Platform.Identity;
public interface IPlatformSignInManager public interface IPlatformSignInManager
{ {
Task<bool> PreSignInCheckAsync(IdentityUser user); Task<bool> PreSignInCheckAsync(IdentityUser user);
Task<bool> CheckConcurrentLimitAsync(IdentityUser user);
} }
public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager
@ -35,6 +36,7 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager
private readonly ITenantRepository tenantRepository; private readonly ITenantRepository tenantRepository;
private readonly IdentityUserManager userManager; private readonly IdentityUserManager userManager;
private readonly IIdentitySessionRepository identitySessionRepository; private readonly IIdentitySessionRepository identitySessionRepository;
private readonly ICurrentTenant currentTenant;
public PlatformSignInManager( public PlatformSignInManager(
IdentityUserManager userManager, IdentityUserManager userManager,
@ -50,7 +52,8 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager
IRepository<IpRestriction, Guid> repositoryIp, IRepository<IpRestriction, Guid> repositoryIp,
IRepository<WorkHour, Guid> repositoryWorkHour, IRepository<WorkHour, Guid> repositoryWorkHour,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
IIdentitySessionRepository identitySessionRepository IIdentitySessionRepository identitySessionRepository,
ICurrentTenant currentTenant
) : base( ) : base(
userManager, userManager,
contextAccessor, contextAccessor,
@ -68,6 +71,7 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
this.userManager = userManager; this.userManager = userManager;
this.identitySessionRepository = identitySessionRepository; this.identitySessionRepository = identitySessionRepository;
this.currentTenant = currentTenant;
} }
public async Task<bool> PreSignInCheckAsync(IdentityUser user) public async Task<bool> PreSignInCheckAsync(IdentityUser user)
@ -102,7 +106,7 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager
{ {
return new PlatformSignInResult() { IsNotAllowed_WorkHour = true }; return new PlatformSignInResult() { IsNotAllowed_WorkHour = true };
} }
if (!await CanSignInConcurrentLimitAsync(user)) if (!await CheckConcurrentLimitAsync(user))
{ {
return new PlatformSignInResult() { IsNotAllowed_ConcurrentUserLimit = true }; return new PlatformSignInResult() { IsNotAllowed_ConcurrentUserLimit = true };
} }
@ -265,11 +269,11 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager
} }
/// <summary> /// <summary>
/// Prevents login when the tenant's concurrent user limit is reached. /// Tenant'a tanımlı MaxConcurrentUsers limitini aşmamak için login'i engeller.
/// Counts distinct active users (by UserId) in AbpSessions for the tenant. /// AbpSessions tablosundaki aktif oturumları sayar (farklı UserId'lere göre distinct).
/// A user who already has an active session is not counted as a new concurrent user. /// Kullanıcının kendisinin zaten oturumu varsa (refresh senaryosu) yeni concurrent user sayılmaz.
/// </summary> /// </summary>
private async Task<bool> CanSignInConcurrentLimitAsync(IdentityUser user) public async Task<bool> CheckConcurrentLimitAsync(IdentityUser user)
{ {
if (!user.TenantId.HasValue) if (!user.TenantId.HasValue)
{ {
@ -283,24 +287,32 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager
} }
var maxConcurrentUsers = tenant.GetMaxConcurrentUsers(); var maxConcurrentUsers = tenant.GetMaxConcurrentUsers();
if (!maxConcurrentUsers.HasValue || maxConcurrentUsers.Value <= 0) if (!maxConcurrentUsers.HasValue || maxConcurrentUsers.Value == 0)
{ {
return true; return true;
} }
var sessions = await identitySessionRepository.GetListAsync(); // Tenant bağlamını explicit olarak set et — hem password hem refresh token akışlarında
var activeUserCount = sessions // doğru tenant izolasyonu için güvenli bir şekilde değiştirilir.
.Where(s => s.UserId != user.Id) using (currentTenant.Change(user.TenantId))
.Select(s => s.UserId)
.Distinct()
.Count();
if (activeUserCount >= maxConcurrentUsers.Value)
{ {
Logger.LogWarning(PlatformEventIds.UserCannotSignInConcurrentUserLimit, var sessions = await identitySessionRepository.GetListAsync();
"Tenant {TenantId} concurrent user limit of {Limit} reached. Active users: {ActiveCount}.",
user.TenantId, maxConcurrentUsers.Value, activeUserCount); // Bu kullanıcı hariç diğer distinct aktif kullanıcı sayısını hesapla.
return false; // 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; return true;

View file

@ -2,20 +2,25 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Sozsoft.Platform.Extensions; using Sozsoft.Platform.Extensions;
using Sozsoft.Platform.Localization; using Sozsoft.Platform.Localization;
using Sozsoft.Sender.Mail; using Sozsoft.Sender.Mail;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using Volo.Abp;
using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
using Volo.Abp.Identity; using Volo.Abp.Identity;
using Volo.Abp.OpenIddict; using Volo.Abp.OpenIddict;
using Volo.Abp.OpenIddict.Controllers; using Volo.Abp.OpenIddict.Controllers;
using Volo.Abp.Security.Claims;
using Volo.Abp.Settings; using Volo.Abp.Settings;
using Volo.Abp.Uow; using Volo.Abp.Uow;
using Volo.Abp.Validation; using Volo.Abp.Validation;
@ -31,17 +36,20 @@ public class PlatformTokenController : TokenController
private readonly ISozsoftEmailSender emailSender; private readonly ISozsoftEmailSender emailSender;
private readonly ICaptchaManager captchaManager; private readonly ICaptchaManager captchaManager;
private readonly IPlatformSignInManager platformSignInManager; private readonly IPlatformSignInManager platformSignInManager;
private readonly IIdentitySessionRepository identitySessionRepository;
private IStringLocalizer LP; private IStringLocalizer LP;
public PlatformTokenController( public PlatformTokenController(
ISozsoftEmailSender emailSender, ISozsoftEmailSender emailSender,
ICaptchaManager captchaManager, ICaptchaManager captchaManager,
IPlatformSignInManager platformSignInManager, IPlatformSignInManager platformSignInManager,
IIdentitySessionRepository identitySessionRepository,
IStringLocalizer<PlatformResource> LP) : base() IStringLocalizer<PlatformResource> LP) : base()
{ {
this.emailSender = emailSender; this.emailSender = emailSender;
this.captchaManager = captchaManager; this.captchaManager = captchaManager;
this.platformSignInManager = platformSignInManager; this.platformSignInManager = platformSignInManager;
this.identitySessionRepository = identitySessionRepository;
this.LP = LP; this.LP = LP;
} }
@ -224,6 +232,117 @@ Your login code: {twoFactorToken}";
return true; return true;
} }
protected override async Task<IActionResult> 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<IActionResult> 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;
} }