diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateUpdateTenantInput.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateUpdateTenantInput.cs index 076826b..fc71a45 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateUpdateTenantInput.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateUpdateTenantInput.cs @@ -28,5 +28,6 @@ public class CreateUpdateTenantInput public string Email { get; set; } public string Website { get; set; } public string MenuGroup { get; set; } + public int MaxConcurrentUsers { get; set; } } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormDynamicApiAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormDynamicApiAppService.cs index 4428079..862ae94 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormDynamicApiAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormDynamicApiAppService.cs @@ -254,6 +254,7 @@ public class ListFormDynamicApiAppService : PlatformAppService, IListFormDynamic entity.SetEmail(input.Data.Email); entity.SetWebsite(input.Data.Website); entity.SetMenuGroup(input.Data.MenuGroup); + entity.SetMaxConcurrentUsers(input.Data.MaxConcurrentUsers); await tenantRepository.UpdateAsync(entity, autoSave: true); } @@ -289,6 +290,7 @@ public class ListFormDynamicApiAppService : PlatformAppService, IListFormDynamic entity.SetEmail(input.Data.Email); entity.SetWebsite(input.Data.Website); entity.SetMenuGroup(input.Data.MenuGroup); + entity.SetMaxConcurrentUsers(input.Data.MaxConcurrentUsers); await tenantRepository.UpdateAsync(entity, autoSave: true); } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs index ddf1df5..3f7a6f3 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs @@ -5090,6 +5090,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency Text = "App.Platform.HangfireLogin", AuthName=listFormName, Url= swaggerRootUrl + "/Account/Login", + Icon = "login", IsVisible = true, }, new() { @@ -5098,6 +5099,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency Text = "App.Platform.HangfireOpen", AuthName=listFormName, Url= swaggerRootUrl + "/hangfire", + Icon = "runner", IsVisible = true, }, new() { @@ -5106,6 +5108,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency Text = "App.Platform.HangfireRefresh", AuthName=listFormName, OnClick="UiEvalService.ApiGenerateBackgroundWorkers();", + Icon = "refresh", IsVisible = true, }, }), @@ -6137,6 +6140,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency Text = "Swagger", AuthName = listFormName, Url= swaggerRootUrl + "/swagger/index.html", + Icon = "doc", IsVisible = true }, }), diff --git a/api/src/Sozsoft.Platform.Domain.Shared/Identity/PlatformClaimTypes.cs b/api/src/Sozsoft.Platform.Domain.Shared/Identity/PlatformClaimTypes.cs index 25b23dd..37838e9 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/Identity/PlatformClaimTypes.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/Identity/PlatformClaimTypes.cs @@ -19,5 +19,11 @@ public static class PlatformClaimTypes public static KeyValuePair IsVerified { get; set; } = new KeyValuePair(PlatformConsts.AbpIdentity.User.IsVerified, "platform.is_verified"); + /// + /// Default: "platform.session_id". + /// + public static KeyValuePair SessionId { get; set; } + = new KeyValuePair("platform.session_id", "platform.session_id"); + } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Extensions/PlatformApplicationConfigurationContributor.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Extensions/PlatformApplicationConfigurationContributor.cs index 9233bd4..62969a6 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Extensions/PlatformApplicationConfigurationContributor.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Extensions/PlatformApplicationConfigurationContributor.cs @@ -1,14 +1,18 @@ using System; +using System.Linq; using System.Threading.Tasks; using Sozsoft.Platform.Entities; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Sozsoft.Platform.Identity; using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; +using Volo.Abp.Identity; +using Volo.Abp.Users; namespace Sozsoft.Platform.Extensions; @@ -29,6 +33,20 @@ public class PlatformApplicationConfigurationContributor : IApplicationConfigura var customComponentRepository = context.ServiceProvider.GetRequiredService>(); var customComponents = await customComponentRepository.GetListAsync() ?? throw new EntityNotFoundException("RecordNotFound"); context.ApplicationConfiguration.SetProperty("customComponents", customComponents); + + var currentUser = context.ServiceProvider.GetRequiredService(); + if (currentUser.Id.HasValue) + { + var sessionRepository = context.ServiceProvider.GetRequiredService(); + var sessions = await sessionRepository.GetListAsync(userId: currentUser.Id.Value); + var claimSessionId = currentUser.FindClaim(PlatformClaimTypes.SessionId.Key)?.Value; + var sessionId = sessions.Exists(session => session.SessionId == claimSessionId) + ? claimSessionId + : sessions.OrderByDescending(session => session.SignedIn).FirstOrDefault()?.SessionId; + + context.ApplicationConfiguration.CurrentUser.SessionId = sessionId; + context.ApplicationConfiguration.SetProperty("currentUserSessionId", sessionId); + } } } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionClaimContext.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionClaimContext.cs new file mode 100644 index 0000000..f27592a --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionClaimContext.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; + +namespace Sozsoft.Platform.Identity; + +public static class PlatformSessionClaimContext +{ + private static readonly AsyncLocal CurrentSessionId = new(); + + public static string SessionId => CurrentSessionId.Value; + + public static IDisposable Change(string sessionId) + { + var previous = CurrentSessionId.Value; + CurrentSessionId.Value = sessionId; + return new DisposeAction(() => CurrentSessionId.Value = previous); + } + + private sealed class DisposeAction : IDisposable + { + private readonly Action action; + + public DisposeAction(Action action) + { + this.action = action; + } + + public void Dispose() + { + action(); + } + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionCleanupWorker.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionCleanupWorker.cs index 9b93bf2..718768b 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionCleanupWorker.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionCleanupWorker.cs @@ -10,6 +10,7 @@ using Volo.Abp.DependencyInjection; using Volo.Abp.Identity; using Volo.Abp.MultiTenancy; using Volo.Abp.TenantManagement; +using Volo.Abp.Timing; using Volo.Abp.Uow; using Sozsoft.Platform.Extensions; @@ -27,26 +28,31 @@ public class PlatformSessionCleanupWorker : ISessionCleanupWorker, ITransientDep private readonly ICurrentTenant currentTenant; private readonly ILogger logger; private readonly IOptions openIddictOptions; + private readonly IUnitOfWorkManager unitOfWorkManager; + private readonly IClock clock; public PlatformSessionCleanupWorker( IIdentitySessionRepository sessionRepo, ITenantRepository tenantRepository, ICurrentTenant currentTenant, ILogger logger, - IOptions openIddictOptions) + IOptions openIddictOptions, + IUnitOfWorkManager unitOfWorkManager, + IClock clock) { this.sessionRepo = sessionRepo; this.tenantRepository = tenantRepository; this.currentTenant = currentTenant; this.logger = logger; this.openIddictOptions = openIddictOptions; + this.unitOfWorkManager = unitOfWorkManager; + this.clock = clock; } - [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); + var cutoff = clock.Now - refreshTokenLifetime - TimeSpan.FromMinutes(5); try { @@ -62,6 +68,8 @@ public class PlatformSessionCleanupWorker : ISessionCleanupWorker, ITransientDep { using (currentTenant.Change(tenantId)) { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var sessions = await sessionRepo.GetListAsync(cancellationToken: cancellationToken); var staleSessions = sessions .Where(s => (s.LastAccessed ?? s.SignedIn) < cutoff) @@ -76,8 +84,10 @@ public class PlatformSessionCleanupWorker : ISessionCleanupWorker, ITransientDep tenantId, session.UserId, session.SignedIn, - DateTime.UtcNow); + clock.Now); } + + await uow.CompleteAsync(cancellationToken); } } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionRevocationHandler.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionRevocationHandler.cs index 8605d0b..50e0080 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionRevocationHandler.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionRevocationHandler.cs @@ -8,7 +8,9 @@ using Volo.Abp.DependencyInjection; using Volo.Abp.Identity; using Volo.Abp.MultiTenancy; using Volo.Abp.Security.Claims; +using Volo.Abp.Uow; using System.Linq; +using Volo.Abp; namespace Sozsoft.Platform.Identity; @@ -23,15 +25,18 @@ public class PlatformSessionRevocationHandler : { private readonly IIdentitySessionRepository sessionRepo; private readonly ICurrentTenant currentTenant; + private readonly IUnitOfWorkManager unitOfWorkManager; private readonly ILogger logger; public PlatformSessionRevocationHandler( IIdentitySessionRepository sessionRepo, ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager, ILogger logger) { this.sessionRepo = sessionRepo; this.currentTenant = currentTenant; + this.unitOfWorkManager = unitOfWorkManager; this.logger = logger; } @@ -45,14 +50,20 @@ public class PlatformSessionRevocationHandler : // Token'dan tenant ID'yi al. var tenantId = ParseTenantId(context.GenericTokenPrincipal?.Claims.FirstOrDefault(c => c.Type == AbpClaimTypes.TenantId)?.Value); + var sessionId = context.GenericTokenPrincipal?.Claims.FirstOrDefault(c => c.Type == PlatformClaimTypes.SessionId.Key)?.Value; try { // Sadece token'ın ait olduğu tenant'taki oturumları sil. using (currentTenant.Change(tenantId)) { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); var sessions = await sessionRepo.GetListAsync(userId: userGuid); - foreach (var session in sessions) + var sessionsToDelete = sessionId.IsNullOrWhiteSpace() + ? sessions + : sessions.Where(session => session.SessionId == sessionId).ToList(); + + foreach (var session in sessionsToDelete) { await sessionRepo.DeleteAsync(session); @@ -63,6 +74,8 @@ public class PlatformSessionRevocationHandler : session.SignedIn, DateTime.UtcNow); } + + await uow.CompleteAsync(); } } catch (Exception ex) diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionValidationMiddleware.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionValidationMiddleware.cs new file mode 100644 index 0000000..b9bb36c --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSessionValidationMiddleware.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Identity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; +using Volo.Abp.Uow; + +namespace Sozsoft.Platform.Identity; + +public class PlatformSessionValidationMiddleware : IMiddleware, ITransientDependency +{ + private readonly IIdentitySessionRepository sessionRepository; + private readonly ICurrentTenant currentTenant; + private readonly IUnitOfWorkManager unitOfWorkManager; + + public PlatformSessionValidationMiddleware( + IIdentitySessionRepository sessionRepository, + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager) + { + this.sessionRepository = sessionRepository; + this.currentTenant = currentTenant; + this.unitOfWorkManager = unitOfWorkManager; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + if (context.User.Identity?.IsAuthenticated != true) + { + await next(context); + return; + } + + var userIdValue = context.User.FindFirst(AbpClaimTypes.UserId)?.Value; + var sessionId = context.User.FindFirst(PlatformClaimTypes.SessionId.Key)?.Value; + + if (!Guid.TryParse(userIdValue, out var userId) || sessionId.IsNullOrWhiteSpace()) + { + await next(context); + return; + } + + var tenantId = ParseTenantId(context.User.FindFirst(AbpClaimTypes.TenantId)?.Value); + using (currentTenant.Change(tenantId)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var sessions = await sessionRepository.GetListAsync(userId: userId); + var isCurrentSession = sessions.Any(session => session.SessionId == sessionId); + await uow.CompleteAsync(); + + if (!isCurrentSession) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + } + + await next(context); + } + + private static Guid? ParseTenantId(string value) + { + return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out var id) ? id : null; + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSignInManager.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSignInManager.cs index 4269776..3e5999c 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSignInManager.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformSignInManager.cs @@ -17,6 +17,7 @@ using Volo.Abp.MultiTenancy; using Volo.Abp.Settings; using Volo.Abp.TenantManagement; using Volo.Abp.Timing; +using Volo.Abp.Uow; using IdentityUser = Volo.Abp.Identity.IdentityUser; using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; @@ -37,6 +38,7 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager private readonly IdentityUserManager userManager; private readonly IIdentitySessionRepository identitySessionRepository; private readonly ICurrentTenant currentTenant; + private readonly IUnitOfWorkManager unitOfWorkManager; public PlatformSignInManager( IdentityUserManager userManager, @@ -53,7 +55,8 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager IRepository repositoryWorkHour, ITenantRepository tenantRepository, IIdentitySessionRepository identitySessionRepository, - ICurrentTenant currentTenant + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager ) : base( userManager, contextAccessor, @@ -72,6 +75,7 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager this.userManager = userManager; this.identitySessionRepository = identitySessionRepository; this.currentTenant = currentTenant; + this.unitOfWorkManager = unitOfWorkManager; } public async Task PreSignInCheckAsync(IdentityUser user) @@ -296,6 +300,7 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager // doğru tenant izolasyonu için güvenli bir şekilde değiştirilir. using (currentTenant.Change(user.TenantId)) { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); var sessions = await identitySessionRepository.GetListAsync(); // Bu kullanıcı hariç diğer distinct aktif kullanıcı sayısını hesapla. @@ -313,6 +318,8 @@ public class PlatformSignInManager : AbpSignInManager, IPlatformSignInManager user.TenantId, maxConcurrentUsers.Value, otherActiveUserCount); return false; } + + await uow.CompleteAsync(); } 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 fc393de..bb0e43d 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformTokenController.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformTokenController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Sozsoft.Platform.Extensions; @@ -37,6 +38,8 @@ public class PlatformTokenController : TokenController private readonly ICaptchaManager captchaManager; private readonly IPlatformSignInManager platformSignInManager; private readonly IIdentitySessionRepository identitySessionRepository; + private readonly IOpenIddictTokenManager openIddictTokenManager; + private readonly IUnitOfWorkManager unitOfWorkManager; private IStringLocalizer LP; public PlatformTokenController( @@ -44,12 +47,16 @@ public class PlatformTokenController : TokenController ICaptchaManager captchaManager, IPlatformSignInManager platformSignInManager, IIdentitySessionRepository identitySessionRepository, + IOpenIddictTokenManager openIddictTokenManager, + IUnitOfWorkManager unitOfWorkManager, IStringLocalizer LP) : base() { this.emailSender = emailSender; this.captchaManager = captchaManager; this.platformSignInManager = platformSignInManager; this.identitySessionRepository = identitySessionRepository; + this.openIddictTokenManager = openIddictTokenManager; + this.unitOfWorkManager = unitOfWorkManager; this.LP = LP; } @@ -235,43 +242,32 @@ Your login code: {twoFactorToken}"; protected override async Task SetSuccessResultAsync(OpenIddictRequest request, IdentityUser user) { - var result = await base.SetSuccessResultAsync(request, user); - await ManageIdentitySessionAsync(user, request); - return result; + var sessionId = request.IsRefreshTokenGrantType() + ? PlatformSessionClaimContext.SessionId + : CreateSessionId(); + + using (PlatformSessionClaimContext.Change(sessionId)) + { + var result = await base.SetSuccessResultAsync(request, user); + await ManageIdentitySessionAsync(user, request, sessionId); + return result; + } } - private async Task ManageIdentitySessionAsync(IdentityUser user, OpenIddictRequest request) + private async Task ManageIdentitySessionAsync(IdentityUser user, OpenIddictRequest request, string sessionId) { // 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); - } + await ReplaceUserSessionAsync(user, request.ClientId, sessionId, checkConcurrentLimit: true); } catch (UserFriendlyException) { throw; } catch (Exception ex) { Logger.LogWarning(ex, "Failed to manage AbpSessions. UserId: {UserId}", user.Id); + throw new UserFriendlyException(PlatformConsts.UserCannotSignInErrors.LoginNotAllowed_ConcurrentUserLimit); } } @@ -279,56 +275,185 @@ Your login code: {twoFactorToken}"; 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); - var userId = info.Principal?.GetClaim(OpenIddictConstants.Claims.Subject); + var tenantId = await ResolveRefreshTenantIdAsync(info); + var userGuid = await ResolveRefreshUserIdAsync(request, info, tenantId); + var sessionId = await ResolveRefreshSessionIdAsync(request, info, tenantId); IdentityUser refreshUser = null; using (CurrentTenant.Change(tenantId)) { - if (!userId.IsNullOrWhiteSpace() && Guid.TryParse(userId, out var userGuid)) + if (userGuid.HasValue) { - refreshUser = await UserManager.FindByIdAsync(userId); - if (refreshUser != null) + if (!await IsCurrentSessionAsync(tenantId, userGuid.Value, sessionId)) { - // Concurrent login limiti refresh token için de geçerlidir; yeni oturum açılmadan önce eskiler temizlenir. - if (!await platformSignInManager.CheckConcurrentLimitAsync(refreshUser)) - throw new UserFriendlyException(PlatformConsts.UserCannotSignInErrors.LoginNotAllowed_ConcurrentUserLimit); - - var existingSessions = await identitySessionRepository.GetListAsync(userId: userGuid); - foreach (var session in existingSessions) - { - await identitySessionRepository.DeleteAsync(session); - } + return InvalidRefreshSessionResult(); } + + refreshUser = await UserManager.FindByIdAsync(userGuid.Value.ToString()); } - var result = await base.HandleRefreshTokenAsync(request); + IActionResult result; + using (PlatformSessionClaimContext.Change(sessionId.IsNullOrWhiteSpace() ? CreateSessionId() : sessionId)) + { + result = await base.HandleRefreshTokenAsync(request); + } if (result is Microsoft.AspNetCore.Mvc.SignInResult && refreshUser != null) { - try - { - await InsertSessionAsync(refreshUser, request.ClientId); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to create IdentitySession after refresh token. UserId: {UserId}", refreshUser.Id); - } + await ReplaceUserSessionAsync( + refreshUser, + request.ClientId, + sessionId.IsNullOrWhiteSpace() ? CreateSessionId() : sessionId, + checkConcurrentLimit: false); } return result; } } - private async Task InsertSessionAsync(IdentityUser user, string clientId) + private async Task ResolveRefreshTenantIdAsync(AuthenticateResult info) + { + var tenantId = ParseTenantId(info.Principal?.Claims.FirstOrDefault(c => c.Type == AbpClaimTypes.TenantId)?.Value); + if (tenantId.HasValue) + { + return tenantId; + } + + var tenant = await TenantConfigurationProvider.GetAsync(saveResolveResult: false); + return tenant?.Id; + } + + private async Task ResolveRefreshUserIdAsync(OpenIddictRequest request, AuthenticateResult info, Guid? tenantId) + { + var userId = info.Principal?.GetClaim(OpenIddictConstants.Claims.Subject); + var userGuid = ParseUserId(userId); + if (userGuid.HasValue) + { + return userGuid; + } + + if (request.RefreshToken.IsNullOrWhiteSpace()) + { + return null; + } + + using (CurrentTenant.Change(tenantId)) + { + var token = await openIddictTokenManager.FindByReferenceIdAsync(request.RefreshToken); + if (token == null) + { + return null; + } + + var subject = await openIddictTokenManager.GetSubjectAsync(token); + return ParseUserId(subject); + } + } + + private async Task ResolveRefreshSessionIdAsync(OpenIddictRequest request, AuthenticateResult info, Guid? tenantId) + { + var sessionId = GetSessionId(info.Principal); + if (!sessionId.IsNullOrWhiteSpace()) + { + return sessionId; + } + + if (request.RefreshToken.IsNullOrWhiteSpace()) + { + return null; + } + + using (CurrentTenant.Change(tenantId)) + { + var token = await openIddictTokenManager.FindByReferenceIdAsync(request.RefreshToken); + if (token == null) + { + return null; + } + + var properties = await openIddictTokenManager.GetPropertiesAsync(token); + return properties.TryGetValue(PlatformClaimTypes.SessionId.Key, out var propertySessionId) + ? propertySessionId.GetString() + : null; + } + } + + private static string CreateSessionId() + { + return Guid.NewGuid().ToString("N"); + } + + private static string GetSessionId(ClaimsPrincipal principal) + { + return principal?.FindFirst(PlatformClaimTypes.SessionId.Key)?.Value; + } + + private async Task IsCurrentSessionAsync(Guid? tenantId, Guid userId, string sessionId) + { + if (sessionId.IsNullOrWhiteSpace()) + { + return true; + } + + using (CurrentTenant.Change(tenantId)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var sessions = await identitySessionRepository.GetListAsync(userId: userId); + var isCurrent = sessions.Any(session => session.SessionId == sessionId); + await uow.CompleteAsync(); + return isCurrent; + } + } + + private async Task ReplaceUserSessionAsync( + IdentityUser user, + string clientId, + string sessionId, + bool checkConcurrentLimit) + { + using (CurrentTenant.Change(user.TenantId)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var existingSessions = await identitySessionRepository.GetListAsync(userId: user.Id); + + if (checkConcurrentLimit && !existingSessions.Any()) + { + if (!await platformSignInManager.CheckConcurrentLimitAsync(user)) + { + throw new UserFriendlyException(PlatformConsts.UserCannotSignInErrors.LoginNotAllowed_ConcurrentUserLimit); + } + } + + foreach (var session in existingSessions) + { + await identitySessionRepository.DeleteAsync(session); + } + + await InsertSessionAsync(user, clientId, sessionId); + await uow.CompleteAsync(); + } + } + + private IActionResult InvalidRefreshSessionResult() + { + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The refresh token session is no longer active." + }); + + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task InsertSessionAsync(IdentityUser user, string clientId, string sessionId) { 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"), + sessionId, "Web", deviceInfo, user.Id, @@ -343,6 +468,9 @@ Your login code: {twoFactorToken}"; private static Guid? ParseTenantId(string value) => !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out var id) ? id : null; + + private static Guid? ParseUserId(string value) => + !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out var id) ? id : null; } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformUserClaimsPrincipalFactory.cs b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformUserClaimsPrincipalFactory.cs index 98ebedf..4d201cb 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformUserClaimsPrincipalFactory.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/Identity/PlatformUserClaimsPrincipalFactory.cs @@ -89,6 +89,12 @@ public class PlatformUserClaimsPrincipalFactory : AbpUserClaimsPrincipalFactory, identity.AddClaim(new Claim(PlatformConsts.AbpIdentity.User.IsVerified, user.GetIsVerified().ToString())); + if (!PlatformSessionClaimContext.SessionId.IsNullOrWhiteSpace()) + { + identity.AddIfNotContains( + new Claim(PlatformClaimTypes.SessionId.Key, PlatformSessionClaimContext.SessionId)); + } + return identity; } } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs index e37d2a4..5d333b7 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs @@ -509,6 +509,7 @@ public class PlatformHttpApiHostModule : AbpModule } app.UseUnitOfWork(); + app.UseMiddleware(); app.UseDynamicClaims(); app.UseAuthorization(); diff --git a/ui/src/proxy/config/models.ts b/ui/src/proxy/config/models.ts index 9429a7c..1f9bbba 100644 --- a/ui/src/proxy/config/models.ts +++ b/ui/src/proxy/config/models.ts @@ -140,6 +140,7 @@ export interface CurrentUserDto { phoneNumber?: string phoneNumberVerified: boolean roles: string[] + sessionId: string } export interface DateTimeFormatDto {