From 2d3f7b18efcb439764bcee702045d89e908bbf35 Mon Sep 17 00:00:00 2001 From: Swapnamol Abraham Date: Fri, 4 Apr 2025 11:12:22 +0100 Subject: [PATCH 1/2] TD-3734: Concurrent Sessions Allowed --- .../LearningHub.Nhs.Auth.Tests.csproj | 2 +- .../Controllers/AccountController.cs | 83 ++++++++++++++----- .../Interfaces/IUserService.cs | 7 ++ .../LearningHub.Nhs.Auth.csproj | 2 +- .../Services/UserService.cs | 24 ++++++ .../IUserHistoryRepository.cs | 7 ++ ...ub.Nhs.UserApi.Repository.Interface.csproj | 2 +- .../LearningHub.Nhs.UserApi.Repository.csproj | 2 +- .../UserHistoryRepository.cs | 24 +++++- .../IUserHistoryService.cs | 7 ++ ...gHub.Nhs.UserAPI.Services.Interface.csproj | 2 +- ...gHub.Nhs.UserApi.Services.UnitTests.csproj | 2 +- .../LearningHub.Nhs.UserApi.Services.csproj | 2 +- .../UserHistoryService.cs | 13 +++ .../LearningHub.Nhs.UserApi.Shared.csproj | 2 +- .../LearningHub.Nhs.UserApi.UnitTests.csproj | 2 +- .../Controllers/UserHistoryController.cs | 13 +++ .../LearningHub.Nhs.UserApi.csproj | 2 +- 18 files changed, 168 insertions(+), 30 deletions(-) diff --git a/Auth/LearningHub.Nhs.Auth.Tests/LearningHub.Nhs.Auth.Tests.csproj b/Auth/LearningHub.Nhs.Auth.Tests/LearningHub.Nhs.Auth.Tests.csproj index e52462d..ec6d6ad 100644 --- a/Auth/LearningHub.Nhs.Auth.Tests/LearningHub.Nhs.Auth.Tests.csproj +++ b/Auth/LearningHub.Nhs.Auth.Tests/LearningHub.Nhs.Auth.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs b/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs index 123fe1a..847b5a7 100644 --- a/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs +++ b/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; + using Azure.Core; using elfhHub.Nhs.Models.Common; using elfhHub.Nhs.Models.Enums; using IdentityModel; @@ -22,9 +23,11 @@ using LearningHub.Nhs.Models.Common; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; + using UAParser; /// /// Account Controller operations. @@ -163,34 +166,76 @@ await this.interaction.GrantConsentAsync( if (loginResult.IsAuthenticated) { - await this.SignInUser(userId, model.Username.Trim(), model.RememberLogin, context.Parameters["ext_referer"]); - - if (context != null) + var uaParser = Parser.GetDefault(); + var clientInfo = uaParser.Parse(this.Request.Headers["User-Agent"]); + var result = await this.UserService.CheckUserHasAnActiveSessionAsync(userId); + if (result.Items.Count == 0 || result.Items[0].BrowserName == clientInfo.UA.Family) { - if (await this.ClientStore.IsPkceClientAsync(context.Client.ClientId)) + await this.SignInUser(userId, model.Username.Trim(), model.RememberLogin, context.Parameters["ext_referer"]); + + if (context != null) { - // if the client is PKCE then we assume it's native, so this change in how to - // return the response is for better UX for the end user. - return this.View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); + if (await this.ClientStore.IsPkceClientAsync(context.Client.ClientId)) + { + // if the client is PKCE then we assume it's native, so this change in how to + // return the response is for better UX for the end user. + return this.View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); + } + + // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null + return this.Redirect(model.ReturnUrl); } - // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null - return this.Redirect(model.ReturnUrl); - } - - // request for a local page - if (this.Url.IsLocalUrl(model.ReturnUrl)) - { - return this.Redirect(model.ReturnUrl); + // request for a local page + if (this.Url.IsLocalUrl(model.ReturnUrl)) + { + return this.Redirect(model.ReturnUrl); + } + else if (string.IsNullOrEmpty(model.ReturnUrl)) + { + return this.Redirect("~/"); + } + else + { + // user might have clicked on a malicious link - should be logged + throw new Exception("invalid return URL"); + } } - else if (string.IsNullOrEmpty(model.ReturnUrl)) + else if (result.Items[0].BrowserName == clientInfo.UA.Family) { - return this.Redirect("~/"); + await this.SignInUser(userId, model.Username.Trim(), model.RememberLogin, context.Parameters["ext_referer"]); + + if (context != null) + { + if (await this.ClientStore.IsPkceClientAsync(context.Client.ClientId)) + { + // if the client is PKCE then we assume it's native, so this change in how to + // return the response is for better UX for the end user. + return this.View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); + } + + // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null + return this.Redirect(model.ReturnUrl); + } + + // request for a local page + if (this.Url.IsLocalUrl(model.ReturnUrl)) + { + return this.Redirect(model.ReturnUrl); + } + else if (string.IsNullOrEmpty(model.ReturnUrl)) + { + return this.Redirect("~/"); + } + else + { + // user might have clicked on a malicious link - should be logged + throw new Exception("invalid return URL"); + } } else { - // user might have clicked on a malicious link - should be logged - throw new Exception("invalid return URL"); + this.ModelState.AddModelError(string.Empty, "already active session"); } } else if (userId > 0) diff --git a/Auth/LearningHub.Nhs.Auth/Interfaces/IUserService.cs b/Auth/LearningHub.Nhs.Auth/Interfaces/IUserService.cs index 3a97b47..e4ae21c 100644 --- a/Auth/LearningHub.Nhs.Auth/Interfaces/IUserService.cs +++ b/Auth/LearningHub.Nhs.Auth/Interfaces/IUserService.cs @@ -114,6 +114,13 @@ public interface IUserService /// Task StoreUserHistoryAsync(UserHistoryViewModel userHistory); + /// + /// check user has an laredy active session. + /// + /// The userId. + /// The . + Task> CheckUserHasAnActiveSessionAsync(int userId); + /// /// The store user history async. /// diff --git a/Auth/LearningHub.Nhs.Auth/LearningHub.Nhs.Auth.csproj b/Auth/LearningHub.Nhs.Auth/LearningHub.Nhs.Auth.csproj index 01041c3..2933aee 100644 --- a/Auth/LearningHub.Nhs.Auth/LearningHub.Nhs.Auth.csproj +++ b/Auth/LearningHub.Nhs.Auth/LearningHub.Nhs.Auth.csproj @@ -101,7 +101,7 @@ - + diff --git a/Auth/LearningHub.Nhs.Auth/Services/UserService.cs b/Auth/LearningHub.Nhs.Auth/Services/UserService.cs index 2ba58b7..98246ec 100644 --- a/Auth/LearningHub.Nhs.Auth/Services/UserService.cs +++ b/Auth/LearningHub.Nhs.Auth/Services/UserService.cs @@ -243,5 +243,29 @@ public async Task StoreUserHistoryAsync(UserHistoryViewModel userHistory) } } } + + /// + public async Task> CheckUserHasAnActiveSessionAsync(int userId) + { + PagedResultSet userHistoryViewModel = new PagedResultSet(); + + var client = this.UserApiHttpClient.GetClient(); + var request = $"UserHistory/CheckUserHasActiveSession/{userId}"; + var response = await client.GetAsync(request).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadAsStringAsync(); + userHistoryViewModel = JsonConvert.DeserializeObject>(result); + } + else if (response.StatusCode == HttpStatusCode.Unauthorized + || + response.StatusCode == HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return userHistoryViewModel; + } } } diff --git a/LearningHub.Nhs.UserApi.Repository.Interface/IUserHistoryRepository.cs b/LearningHub.Nhs.UserApi.Repository.Interface/IUserHistoryRepository.cs index e72aebf..8684ba8 100644 --- a/LearningHub.Nhs.UserApi.Repository.Interface/IUserHistoryRepository.cs +++ b/LearningHub.Nhs.UserApi.Repository.Interface/IUserHistoryRepository.cs @@ -59,5 +59,12 @@ public interface IUserHistoryRepository /// The . /// Task GetPagedByUserIdAsync(int userId, int startPage, int pageSize); + + /// + /// Check user has an active login session. + /// + /// The userId. + /// The . + Task CheckUserHasActiveSessionAsync(int userId); } } \ No newline at end of file diff --git a/LearningHub.Nhs.UserApi.Repository.Interface/LearningHub.Nhs.UserApi.Repository.Interface.csproj b/LearningHub.Nhs.UserApi.Repository.Interface/LearningHub.Nhs.UserApi.Repository.Interface.csproj index fc69964..29a3a90 100644 --- a/LearningHub.Nhs.UserApi.Repository.Interface/LearningHub.Nhs.UserApi.Repository.Interface.csproj +++ b/LearningHub.Nhs.UserApi.Repository.Interface/LearningHub.Nhs.UserApi.Repository.Interface.csproj @@ -8,7 +8,7 @@ - + all diff --git a/LearningHub.Nhs.UserApi.Repository/LearningHub.Nhs.UserApi.Repository.csproj b/LearningHub.Nhs.UserApi.Repository/LearningHub.Nhs.UserApi.Repository.csproj index 4644295..5921669 100644 --- a/LearningHub.Nhs.UserApi.Repository/LearningHub.Nhs.UserApi.Repository.csproj +++ b/LearningHub.Nhs.UserApi.Repository/LearningHub.Nhs.UserApi.Repository.csproj @@ -8,7 +8,7 @@ - + diff --git a/LearningHub.Nhs.UserApi.Repository/UserHistoryRepository.cs b/LearningHub.Nhs.UserApi.Repository/UserHistoryRepository.cs index 673a3ae..3f0386b 100644 --- a/LearningHub.Nhs.UserApi.Repository/UserHistoryRepository.cs +++ b/LearningHub.Nhs.UserApi.Repository/UserHistoryRepository.cs @@ -9,6 +9,7 @@ using LearningHub.Nhs.UserApi.Repository.Interface; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; + using Newtonsoft.Json.Linq; /// /// The user history repository. @@ -66,11 +67,13 @@ public async Task CreateAsync(int userId, int tenantId, UserHistoryViewModel use new SqlParameter("@LoginIP", SqlDbType.VarChar) { Value = userHistoryVM.LoginIP ?? (object)DBNull.Value }, new SqlParameter("@LoginSuccessFul", SqlDbType.Bit) { Value = userHistoryVM.LoginSuccessFul ?? (object)DBNull.Value }, new SqlParameter("@TenantId", SqlDbType.Int) { Value = tenantId }, + new SqlParameter("@SessionId", SqlDbType.VarChar) { Value = (userHistoryVM.UserHistoryTypeId == 0 && userHistoryVM.Detail == "User logged on. Source of auth: LearningHub.Nhs.Auth Account\\Login") ? userHistoryVM.SessionId : (object)DBNull.Value }, + new SqlParameter("@IsActive", SqlDbType.Bit) { Value = (userHistoryVM.UserHistoryTypeId == 0 && userHistoryVM.Detail == "User logged on. Source of auth: LearningHub.Nhs.Auth Account\\Login") ? userHistoryVM.IsActive : (object)DBNull.Value }, new SqlParameter("@AmendUserId", SqlDbType.Int) { Value = userId }, new SqlParameter("@AmendDate", SqlDbType.DateTimeOffset) { Value = DateTimeOffset.Now }, }; - string sql = "proc_UserHistoryInsert @UserId, @UserHistoryTypeId, @Detail, @UserAgent, @BrowserName, @BrowserVersion, @UrlReferer, @LoginIP, @LoginSuccessFul, @TenantId, @AmendUserId, @AmendDate"; + string sql = "proc_UserHistoryInsert @UserId, @UserHistoryTypeId, @Detail, @UserAgent, @BrowserName, @BrowserVersion, @UrlReferer, @LoginIP, @LoginSuccessFul, @TenantId, @SessionId, @IsActive, @AmendUserId, @AmendDate"; await this.DbContext.Database.ExecuteSqlRawAsync(sql, sqlParams); } @@ -98,5 +101,24 @@ public async Task GetPagedByUserIdAsync(int userId return retVal; } + + /// + public async Task CheckUserHasActiveSessionAsync(int userId) + { + try + { + var retVal = new UserHistoryStoredProcResults(); + var param0 = new SqlParameter("@p0", SqlDbType.Int) { Value = userId }; + + var result = await this.DbContext.Set().FromSqlRaw( + "dbo.proc_ActiveLearningHubUserbyId @p0", param0).AsNoTracking().ToListWithNoLockAsync(); + retVal.Results = result; + return retVal; + } + catch (Exception ex) + { + return null; + } + } } } \ No newline at end of file diff --git a/LearningHub.Nhs.UserApi.Services.Interface/IUserHistoryService.cs b/LearningHub.Nhs.UserApi.Services.Interface/IUserHistoryService.cs index 7ed5e01..9c6954b 100644 --- a/LearningHub.Nhs.UserApi.Services.Interface/IUserHistoryService.cs +++ b/LearningHub.Nhs.UserApi.Services.Interface/IUserHistoryService.cs @@ -53,5 +53,12 @@ public interface IUserHistoryService /// The . /// Task> GetUserHistoryPageAsync(int page, int pageSize, string sortColumn = "", string sortDirection = "", string presetFilter = "", string filter = ""); + + /// + /// Check user has an active login session. + /// + /// The userId. + /// The . + Task> CheckUserHasActiveSessionAsync(int userId); } } diff --git a/LearningHub.Nhs.UserApi.Services.Interface/LearningHub.Nhs.UserAPI.Services.Interface.csproj b/LearningHub.Nhs.UserApi.Services.Interface/LearningHub.Nhs.UserAPI.Services.Interface.csproj index fc69964..29a3a90 100644 --- a/LearningHub.Nhs.UserApi.Services.Interface/LearningHub.Nhs.UserAPI.Services.Interface.csproj +++ b/LearningHub.Nhs.UserApi.Services.Interface/LearningHub.Nhs.UserAPI.Services.Interface.csproj @@ -8,7 +8,7 @@ - + all diff --git a/LearningHub.Nhs.UserApi.Services.UnitTests/LearningHub.Nhs.UserApi.Services.UnitTests.csproj b/LearningHub.Nhs.UserApi.Services.UnitTests/LearningHub.Nhs.UserApi.Services.UnitTests.csproj index 78785b5..a61a661 100644 --- a/LearningHub.Nhs.UserApi.Services.UnitTests/LearningHub.Nhs.UserApi.Services.UnitTests.csproj +++ b/LearningHub.Nhs.UserApi.Services.UnitTests/LearningHub.Nhs.UserApi.Services.UnitTests.csproj @@ -10,7 +10,7 @@ - + diff --git a/LearningHub.Nhs.UserApi.Services/LearningHub.Nhs.UserApi.Services.csproj b/LearningHub.Nhs.UserApi.Services/LearningHub.Nhs.UserApi.Services.csproj index 0b91349..670491b 100644 --- a/LearningHub.Nhs.UserApi.Services/LearningHub.Nhs.UserApi.Services.csproj +++ b/LearningHub.Nhs.UserApi.Services/LearningHub.Nhs.UserApi.Services.csproj @@ -8,7 +8,7 @@ - + diff --git a/LearningHub.Nhs.UserApi.Services/UserHistoryService.cs b/LearningHub.Nhs.UserApi.Services/UserHistoryService.cs index c65377c..e6ef86c 100644 --- a/LearningHub.Nhs.UserApi.Services/UserHistoryService.cs +++ b/LearningHub.Nhs.UserApi.Services/UserHistoryService.cs @@ -1,5 +1,6 @@ namespace LearningHub.Nhs.UserApi.Services { + using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -56,6 +57,8 @@ public async Task CreateAsync(UserHistoryViewModel if (retVal.IsValid) { + userHistoryVM.SessionId = Guid.NewGuid().ToString(); + userHistoryVM.IsActive = true; await this.userHistoryRepository.CreateAsync(userHistoryVM.UserId, this.settings.LearningHubTenantId, userHistoryVM); } @@ -99,6 +102,16 @@ public async Task> GetUserHistoryPageAsync( return result; } + /// + public async Task> CheckUserHasActiveSessionAsync(int userId) + { + PagedResultSet result = new PagedResultSet(); + var userHistory = await this.userHistoryRepository.CheckUserHasActiveSessionAsync(userId); + userHistory.Results.ForEach(x => x.UserAgent = this.ParseUserAgentString(x.UserAgent)); + result.Items = this.mapper.Map>(userHistory.Results); + return result; + } + private string ParseUserAgentString(string userAgent) { string retVal = string.Empty; diff --git a/LearningHub.Nhs.UserApi.Shared/LearningHub.Nhs.UserApi.Shared.csproj b/LearningHub.Nhs.UserApi.Shared/LearningHub.Nhs.UserApi.Shared.csproj index 149be25..ecfe311 100644 --- a/LearningHub.Nhs.UserApi.Shared/LearningHub.Nhs.UserApi.Shared.csproj +++ b/LearningHub.Nhs.UserApi.Shared/LearningHub.Nhs.UserApi.Shared.csproj @@ -8,7 +8,7 @@ - + all diff --git a/LearningHub.Nhs.UserApi.UnitTests/LearningHub.Nhs.UserApi.UnitTests.csproj b/LearningHub.Nhs.UserApi.UnitTests/LearningHub.Nhs.UserApi.UnitTests.csproj index fda3b53..5c215c5 100644 --- a/LearningHub.Nhs.UserApi.UnitTests/LearningHub.Nhs.UserApi.UnitTests.csproj +++ b/LearningHub.Nhs.UserApi.UnitTests/LearningHub.Nhs.UserApi.UnitTests.csproj @@ -10,7 +10,7 @@ - + diff --git a/LearningHub.Nhs.UserApi/Controllers/UserHistoryController.cs b/LearningHub.Nhs.UserApi/Controllers/UserHistoryController.cs index f86d769..d1436d4 100644 --- a/LearningHub.Nhs.UserApi/Controllers/UserHistoryController.cs +++ b/LearningHub.Nhs.UserApi/Controllers/UserHistoryController.cs @@ -100,6 +100,19 @@ public async Task GetUserHistoryPageAsync(int page, int pageSize, return this.Ok(pagedResultSet); } + /// + /// Check the user has an active login session. + /// + /// The UserId. + /// The . + [HttpGet] + [Route("CheckUserHasActiveSession/{userId}")] + public async Task CheckUserHasActiveSessionAsync(int userId) + { + PagedResultSet pagedResultSet = await this.userHistoryService.CheckUserHasActiveSessionAsync(userId); + return this.Ok(pagedResultSet); + } + /// /// Create a UserHistory. /// diff --git a/LearningHub.Nhs.UserApi/LearningHub.Nhs.UserApi.csproj b/LearningHub.Nhs.UserApi/LearningHub.Nhs.UserApi.csproj index 50f8813..c707f5a 100644 --- a/LearningHub.Nhs.UserApi/LearningHub.Nhs.UserApi.csproj +++ b/LearningHub.Nhs.UserApi/LearningHub.Nhs.UserApi.csproj @@ -23,7 +23,7 @@ - + From 58ea79f9c3de350e57ed2a7432bc5db45fb6e56b Mon Sep 17 00:00:00 2001 From: Swapnamol Abraham Date: Fri, 4 Apr 2025 11:36:04 +0100 Subject: [PATCH 2/2] Concurrent Sessions Allowed --- .../Controllers/AccountController.cs | 34 +------------------ .../Views/Account/AlreadyActiveSession.cshtml | 15 ++++++++ 2 files changed, 16 insertions(+), 33 deletions(-) create mode 100644 Auth/LearningHub.Nhs.Auth/Views/Account/AlreadyActiveSession.cshtml diff --git a/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs b/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs index 847b5a7..58f804d 100644 --- a/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs +++ b/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs @@ -201,41 +201,9 @@ await this.interaction.GrantConsentAsync( throw new Exception("invalid return URL"); } } - else if (result.Items[0].BrowserName == clientInfo.UA.Family) - { - await this.SignInUser(userId, model.Username.Trim(), model.RememberLogin, context.Parameters["ext_referer"]); - - if (context != null) - { - if (await this.ClientStore.IsPkceClientAsync(context.Client.ClientId)) - { - // if the client is PKCE then we assume it's native, so this change in how to - // return the response is for better UX for the end user. - return this.View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); - } - - // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null - return this.Redirect(model.ReturnUrl); - } - - // request for a local page - if (this.Url.IsLocalUrl(model.ReturnUrl)) - { - return this.Redirect(model.ReturnUrl); - } - else if (string.IsNullOrEmpty(model.ReturnUrl)) - { - return this.Redirect("~/"); - } - else - { - // user might have clicked on a malicious link - should be logged - throw new Exception("invalid return URL"); - } - } else { - this.ModelState.AddModelError(string.Empty, "already active session"); + return this.View("AlreadyActiveSession"); } } else if (userId > 0) diff --git a/Auth/LearningHub.Nhs.Auth/Views/Account/AlreadyActiveSession.cshtml b/Auth/LearningHub.Nhs.Auth/Views/Account/AlreadyActiveSession.cshtml new file mode 100644 index 0000000..1f5d3b6 --- /dev/null +++ b/Auth/LearningHub.Nhs.Auth/Views/Account/AlreadyActiveSession.cshtml @@ -0,0 +1,15 @@ +@{ + ViewData["Title"] = "Already active session"; +} +
+
+
+
+

@ViewData["Title"]

+

You are already logged in from another browser. Please continue using the same browser or close the existing session and try again with a new one.

+

If you have any questions, please contact the support team.

+

@DateTimeOffset.Now.ToString("d MMMM yyyy HH:mm:ss")

+
+
+
+
\ No newline at end of file