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 MailQueueWorker(
ISozsoftEmailSender ErpEmailSender,
ISozsoftEmailSender erpEmailSender,
IRepository<Entities.BackgroundWorker_MailQueue> 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;

View file

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

View file

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

View file

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

View file

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

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

View file

@ -5,5 +5,6 @@ public enum WorkerTypeEnum
MailQueueWorker = 1,
SqlWorker = 2,
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);
}
else if (Worker.WorkerType == WorkerTypeEnum.SessionCleanupWorker)
{
await LazyServiceProvider.GetRequiredService<ISessionCleanupWorker>().StartAsync(cancellationToken);
}
// Call AfterSp
if (!Worker.AfterSp.IsNullOrWhiteSpace())

View file

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

View file

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

View file

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

View file

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

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.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<bool> PreSignInCheckAsync(IdentityUser user);
Task<bool> 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<IpRestriction, Guid> repositoryIp,
IRepository<WorkHour, Guid> 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<bool> 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
}
/// <summary>
/// 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.
/// </summary>
private async Task<bool> CanSignInConcurrentLimitAsync(IdentityUser user)
public async Task<bool> 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;

View file

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