diff --git a/.github/azure-pipelines-ci.yml b/.github/azure-pipelines-ci.yml index ef18a34..524baab 100644 --- a/.github/azure-pipelines-ci.yml +++ b/.github/azure-pipelines-ci.yml @@ -24,7 +24,7 @@ jobs: - task: NodeTool@0 displayName: Use Node 12.19 inputs: - versionSpec: 12.19 + versionSpec: 14 - task: Npm@1 displayName: npm custom inputs: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..576e74c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,69 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +registries: + tel-azure-package-source: + type: nuget-feed + url: "https://pkgs.dev.azure.com/e-LfH/_packaging/LearningHubFeed/nuget/v3/index.json" + username: "kevin.whittaker" + password: ${{ secrets.AZURE_DEVOPS_PAT }} + nuget-package-source: + type: nuget-feed + url: "https://api.nuget.org/v3/index.json" +updates: + - package-ecosystem: "nuget" + directory: "/" # Location of package manifests + schedule: + interval: "daily" + open-pull-requests-limit: 10 + registries: + - tel-azure-package-source + - nuget-package-source + target-branch: "Automatic_version_update_dependabot" + ignore: + # Ignore updates to packages that start with 'Wildcards' + - dependency-name: "elfhHub.Nhs.Models*" + - dependency-name: "LearningHub.Nhs.Models*" + - dependency-name: "LearningHub.Nhs.Caching*" + - dependency-name: "NHSUKViewComponents.Web*" + # Ignore some updates to the package + - dependency-name: "Microsoft.VisualStudio.Web.CodeGeneration.Design" + versions: [">7.0.0"] + - dependency-name: "Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" + versions: [">7.0.0"] + - dependency-name: "Microsoft.AspNetCore.Mvc.Testing" + versions: [">7.0.0"] + - dependency-name: "Microsoft.AspNetCore.Authentication.JwtBearer" + versions: [">7.0.0"] + - dependency-name: "Microsoft.AspNetCore.Authentication.OpenIdConnect" + versions: [">7.0.0"] + - dependency-name: "Microsoft.AspNetCore.Mvc.NewtonsoftJson" + versions: [">7.0.0"] + - dependency-name: "Microsoft.EntityFrameworkCore" + versions: [">7.0.0"] + - dependency-name: "Microsoft.EntityFrameworkCore.SqlServer" + versions: [">7.0.0"] + - dependency-name: "Microsoft.Extensions.Caching.StackExchangeRedis" + versions: [">7.0.0"] + - dependency-name: "Microsoft.Extensions.Logging.Console" + versions: [">7.0.0"] + - dependency-name: "Selenium.WebDriver.ChromeDriver" + versions: ">=113.0.5672.1278" # Recommended version + # For all packages, ignore all patch updates + #- dependency-name: "*" + # update-types: ["version-update:semver-patch"] + + # Configuration for npm Auth + - package-ecosystem: "npm" + directory: "Auth/LearningHub.Nhs.Auth/" # Location of package manifests + schedule: + interval: "daily" + target-branch: "Automatic_version_update_dependabot" + # - "dependencies" + open-pull-requests-limit: 10 + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..216206e --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,183 @@ +name: Auto Merge Dependabot PRs + +on: + pull_request: + types: + - opened + - synchronize + +permissions: + pull-requests: write + contents: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + + steps: + - name: Checkout the repository + uses: actions/checkout@v3 + + - name: Set up GitHub CLI + run: | + # Install GitHub CLI (gh) + sudo apt-get update + sudo apt-get install gh + + # Authenticate GitHub CLI using the provided token + echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token + + - name: Wait for CI workflow to pass (Ensure CI workflow succeeded) + id: wait_for_ci + run: | + # Get the PR number from the GitHub event + PR_NUMBER=${{ github.event.pull_request.number }} + echo "Checking CI status for PR #$PR_NUMBER" + + # Define the maximum wait time (in seconds) and the polling interval (in seconds) + MAX_WAIT_TIME=1800 # 30 minutes + POLL_INTERVAL=10 # Check every 10 seconds + + # Initialize a timer + elapsed_time=0 + + # Poll CI status until all checks are completed + while true; do + # Fetch the status check rollup for the PR + CI_STATUS=$(gh pr view $PR_NUMBER --json statusCheckRollup) + + # Log the fetched response + echo "CI Status Response: $CI_STATUS" + + # Parse the checks and check their status + ALL_COMPLETED=true + ALL_CHECKS_PASSED=true + + for check in $(echo "$CI_STATUS" | jq -r '.statusCheckRollup[] | @base64'); do + _jq() { + echo "${check}" | base64 --decode | jq -r "${1}" + } + + status=$(_jq '.status') + conclusion=$(_jq '.conclusion') + check_name=$(_jq '.name') + + # Log check details + echo "Check: $check_name, Status: $status, Conclusion: $conclusion" + if [[ "$check_name" == "auto-merge" ]]; then + echo "Skipping 'auto-merge' workflow check to prevent self-referencing." + continue + fi + + # If any check is still queued, set ALL_COMPLETED to false + if [[ "$status" == "QUEUED" ]]; then + ALL_COMPLETED=false + fi + + # If any check is still in progress, set ALL_COMPLETED to false + if [[ "$status" == "IN_PROGRESS" ]]; then + ALL_COMPLETED=false + fi + + # If any completed check has failed, set ALL_CHECKS_PASSED to false + if [[ "$status" == "COMPLETED" && "$conclusion" != "SUCCESS" ]]; then + ALL_CHECKS_PASSED=false + fi + done + + # Break the loop if all checks are completed + if [[ "$ALL_COMPLETED" == true ]]; then + break + fi + + # Wait for the next polling interval + echo "Waiting for checks to complete... ($elapsed_time/$MAX_WAIT_TIME seconds elapsed)" + sleep $POLL_INTERVAL + elapsed_time=$((elapsed_time + POLL_INTERVAL)) + + # Exit if the maximum wait time is exceeded + if [[ "$elapsed_time" -ge "$MAX_WAIT_TIME" ]]; then + echo "Timed out waiting for CI checks to complete." + exit 1 + fi + done + + # Final check: Ensure all CI checks passed + if [[ "$ALL_CHECKS_PASSED" == false ]]; then + echo "One or more CI checks failed. Aborting merge." + exit 1 + fi + + echo "All CI checks passed successfully." + + + - name: Check Target Branch and PR Title + id: check_branch + run: | + PR_TITLE='${{ github.event.pull_request.title }}' + echo "Original PR Title: $PR_TITLE" + + # Escape problematic quotes + ESCAPED_TITLE=$(echo "$PR_TITLE" | sed 's/"/\\"/g') + echo "Escaped PR Title: $ESCAPED_TITLE" + + if [[ "$ESCAPED_TITLE" =~ ([0-9]+\.[0-9]+\.[0-9]+).*to.*([0-9]+\.[0-9]+\.[0-9]+) ]]; then + # Extract version numbers + OLD_VERSION="${BASH_REMATCH[1]}" + NEW_VERSION="${BASH_REMATCH[2]}" + echo "Version change detected: $OLD_VERSION to $NEW_VERSION" + + # Split version into major, minor, patch components + OLD_MAJOR=$(echo "$OLD_VERSION" | cut -d '.' -f1) + OLD_MINOR=$(echo "$OLD_VERSION" | cut -d '.' -f2) + OLD_PATCH=$(echo "$OLD_VERSION" | cut -d '.' -f3) + + NEW_MAJOR=$(echo "$NEW_VERSION" | cut -d '.' -f1) + NEW_MINOR=$(echo "$NEW_VERSION" | cut -d '.' -f2) + NEW_PATCH=$(echo "$NEW_VERSION" | cut -d '.' -f3) + + # Check if it's a minor or patch update + if [[ "$OLD_MAJOR" == "$NEW_MAJOR" ]] && [[ "$OLD_MINOR" == "$NEW_MINOR" ]] && [[ "$NEW_PATCH" -gt "$OLD_PATCH" ]]; then + echo "Patch update detected" + echo "should_merge=true" >> $GITHUB_ENV + elif [[ "$OLD_MAJOR" == "$NEW_MAJOR" ]] && [[ "$NEW_MINOR" -gt "$OLD_MINOR" ]]; then + echo "Minor update detected" + echo "should_merge=true" >> $GITHUB_ENV + else + echo "No minor/patch update detected" + echo "should_merge=false" >> $GITHUB_ENV + fi + else + echo "No version change detected" + echo "should_merge=false" >> $GITHUB_ENV + fi + + - name: Debug Context + uses: actions/github-script@v6 + with: + script: | + console.log("Target branch:", context.payload.pull_request.base.ref); + + - name: Check if Should Merge + run: | + echo "DEBUG: should_merge=${{ env.should_merge }}" + if [[ "${{ env.should_merge }}" == "true" ]] && [[ "${{ github.event.pull_request.base.ref }}" == "Automatic_version_update_dependabot" ]]; then + echo "DEBUG: should merge PR" + echo "should_merge=true" >> $GITHUB_ENV + else + echo "DEBUG: skip merge" + echo "should_merge=false" >> $GITHUB_ENV + fi + + - name: Merge Pull Request + if: ${{ env.should_merge == 'true' }} + uses: actions/github-script@v6 + with: + script: | + github.rest.pulls.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + merge_method: "squash" + }); diff --git a/.github/workflows/continuous-integration-workflow.yml b/.github/workflows/continuous-integration-workflow.yml index c0e0da4..baa6759 100644 --- a/.github/workflows/continuous-integration-workflow.yml +++ b/.github/workflows/continuous-integration-workflow.yml @@ -12,10 +12,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup .NET Core SDK 6.0 + - name: Setup .NET Core SDK 8.0 uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Add Azure artifact run: dotnet nuget add source 'https://pkgs.dev.azure.com/e-LfH/_packaging/LearningHubFeed/nuget/v3/index.json' --name 'LearningHubFeed' --username 'kevin.whittaker' --password ${{ secrets.AZURE_DEVOPS_PAT }} --store-password-in-clear-text @@ -26,42 +26,94 @@ jobs: - name: Use Node 12.19 uses: actions/setup-node@v4 with: - node-version: '12.19' - + node-version: '18' + - name: Install dependencies run: | cd ./Auth/LearningHub.Nhs.Auth - npm install -f + yarn install - name: Run Webpack build run: | cd ./Auth/LearningHub.Nhs.Auth - npm run build + yarn build:webpack - name: Setup MSBuild uses: microsoft/setup-msbuild@v1.0.3 - name: Build SQL Server Database project run: | - # List all .sqlproj files except for .sqlproj + # Enable strict error handling + $ErrorActionPreference = 'Stop' + + # Initialize an error collection + $errors = @() + + # List all .sqlproj files $sqlproj_files = Get-ChildItem -Path . -Filter *.sqlproj -Recurse - - # Build each .csproj file - foreach ($sqlproj_file in $sqlproj_files) { - Write-Host "Building $($sqlproj_file.FullName)" - msbuild "$($sqlproj_file.FullName)" /p:Configuration=Release - } - - - name: Build solution excluding SQL project + + if ($sqlproj_files.Count -eq 0) { + $errors += "No .sqlproj files found." + } else { + foreach ($sqlproj_file in $sqlproj_files) { + Write-Host "Building $($sqlproj_file.FullName)" + try { + $output = &msbuild "$($sqlproj_file.FullName)" /p:Configuration=Release /nologo 2>&1 + if (!$?) { + $errors += "Failed to build $($csproj_file.FullName): $output" + } + } catch { + # Capture detailed error information + $errorMessage = "Error building $($sqlproj_file.FullName): $($_.Exception.Message)" + Write-Host $errorMessage + $errors += $errorMessage + } + } + } + + # Display all accumulated errors + if ($errors.Count -gt 0) { + Write-Host "SQL Project Build Errors:" + $errors | ForEach-Object { Write-Host $_ } + exit 1 + } + + - name: Build Solution Excluding SQL Project run: | - # List all .csproj files except for .sqlproj + # Enable strict error handling + $ErrorActionPreference = 'Stop' + + # Initialize an error collection + $errors = @() + + # List all .csproj files except .sqlproj $csproj_files = Get-ChildItem -Path . -Filter *.csproj -Recurse | Where-Object { $_.FullName -notmatch '\\.sqlproj$' } - - # Build each .csproj file - foreach ($csproj_file in $csproj_files) { - Write-Host "Building $($csproj_file.FullName)" - dotnet build "$($csproj_file.FullName)" - } + + if ($csproj_files.Count -eq 0) { + $errors += "No .csproj files found." + } else { + foreach ($csproj_file in $csproj_files) { + Write-Host "Building $($csproj_file.FullName)" + try { + $output = &dotnet build "$($csproj_file.FullName)" --configuration Release 2>&1 + if (!$?) { + $errors += "Failed to build $($csproj_file.FullName): $output" + } + } catch { + # Capture detailed error information + $errorMessage = "Error building $($csproj_file.FullName): $($_.Exception.Message)" + Write-Host $errorMessage + $errors += $errorMessage + } + } + } + + # Display all accumulated errors + if ($errors.Count -gt 0) { + Write-Host "Solution Build Errors:" + $errors | ForEach-Object { Write-Host $_ } + exit 1 + } #- name: Test # run: dotnet test ${{ env.BuildParameters.TestProjects }} 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 a0a0513..ec6d6ad 100644 --- a/Auth/LearningHub.Nhs.Auth.Tests/LearningHub.Nhs.Auth.Tests.csproj +++ b/Auth/LearningHub.Nhs.Auth.Tests/LearningHub.Nhs.Auth.Tests.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0 true false x64 @@ -9,17 +9,17 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Auth/LearningHub.Nhs.Auth/Configuration/ServiceMappings.cs b/Auth/LearningHub.Nhs.Auth/Configuration/ServiceMappings.cs index 064aa10..79a6731 100644 --- a/Auth/LearningHub.Nhs.Auth/Configuration/ServiceMappings.cs +++ b/Auth/LearningHub.Nhs.Auth/Configuration/ServiceMappings.cs @@ -40,12 +40,22 @@ public static void AddServiceMappings(this IServiceCollection services, IConfigu ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, }); + + services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler( + () => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }); } else { services.AddHttpClient(); + services.AddHttpClient(); } + services.AddScoped(); services.AddDistributedMemoryCache(); services.AddScoped(); services.AddTransient(); diff --git a/Auth/LearningHub.Nhs.Auth/Configuration/WebSettings.cs b/Auth/LearningHub.Nhs.Auth/Configuration/WebSettings.cs index 54dab61..0634fe6 100644 --- a/Auth/LearningHub.Nhs.Auth/Configuration/WebSettings.cs +++ b/Auth/LearningHub.Nhs.Auth/Configuration/WebSettings.cs @@ -56,5 +56,15 @@ public class WebSettings /// Gets or sets the SupportFeedbackForm. /// public string SupportFeedbackForm { get; set; } - } + + /// + /// Gets or sets a value indicating whether IsPasswordUpdate. + /// + public bool IsPasswordUpdate { get; set; } + + /// + /// Gets or sets a value indicating whether gets or sets a value to Enable Moodle. + /// + public bool EnableMoodle { get; set; } + } } diff --git a/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs b/Auth/LearningHub.Nhs.Auth/Controllers/AccountController.cs index 7764ad8..58f804d 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; @@ -20,15 +21,13 @@ using LearningHub.Nhs.Auth.Models.Account; using LearningHub.Nhs.Caching; using LearningHub.Nhs.Models.Common; - using LearningHub.Nhs.Models.Entities.Reporting; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; - using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; - using NHSUKViewComponents.Web.ViewModels; + using UAParser; /// /// Account Controller operations. @@ -72,7 +71,7 @@ public AccountController( this.authConfig = authConfig?.Value; this.webSettings = webSettings; this.logger = logger; - } + } /// /// Shows the Login page. @@ -167,34 +166,44 @@ 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); - } - else if (string.IsNullOrEmpty(model.ReturnUrl)) - { - return this.Redirect("~/"); + // 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"); + return this.View("AlreadyActiveSession"); } } else if (userId > 0) @@ -214,9 +223,9 @@ await this.UserService.AddLogonToUserHistory( this.ModelState.AddModelError(string.Empty, loginResult.ErrorMessage); } - showFormWithError: +showFormWithError: - // something went wrong, show form with error +// something went wrong, show form with error var vm = await this.BuildLoginViewModelAsync(model); if ((vm.ClientId == "learninghubwebclient") || (vm.ClientId == "learninghubadmin")) { @@ -268,6 +277,9 @@ public async Task Logout(LogoutInputModel model) // delete local authentication cookie await this.HttpContext.SignOutAsync(); + // Delete the authentication cookie to ensure it is invalidated + this.HttpContext.Response.Cookies.Delete(".AspNetCore.Identity.Application"); + // raise the logout event await this.Events.RaiseAsync(new UserLogoutSuccessEvent(this.User.GetSubjectId(), this.User.GetDisplayName())); @@ -296,7 +308,15 @@ public async Task Logout(LogoutInputModel model) return this.SignOut(new AuthenticationProperties { RedirectUri = url }, vm.ExternalAuthenticationScheme); } - return this.View("LoggedOut", vm); + if (this.webSettings.IsPasswordUpdate) + { + var redirectUri = $"{this.webSettings.LearningHubWebClient}Home/ChangePasswordAcknowledgement"; + return this.Redirect(redirectUri); + } + else + { + return this.View("LoggedOut", vm); + } } /// @@ -427,6 +447,7 @@ private async Task BuildLoginViewModelAsync(LoginInputModel mode { var vm = await this.BuildLoginViewModelAsync(model.ReturnUrl); vm.Username = model.Username; + vm.Password = model.Password; vm.RememberLogin = model.RememberLogin; return vm; } diff --git a/Auth/LearningHub.Nhs.Auth/Controllers/HomeController.cs b/Auth/LearningHub.Nhs.Auth/Controllers/HomeController.cs index 7855fea..aa0937f 100644 --- a/Auth/LearningHub.Nhs.Auth/Controllers/HomeController.cs +++ b/Auth/LearningHub.Nhs.Auth/Controllers/HomeController.cs @@ -80,6 +80,27 @@ public async Task Error() return this.View("Error"); } + /// + /// IsPasswordUpdateMethod. + /// + /// The Logout. + /// The . + [HttpGet] + public IActionResult SetIsPasswordUpdate(bool isLogout) + { + if (isLogout) + { + this.webSettings.IsPasswordUpdate = false; + } + else + { + this.webSettings.IsPasswordUpdate = true; + } + + var redirectUri = $"{this.webSettings.LearningHubWebClient}Home/UserLogout"; + return this.Redirect(redirectUri); + } + /// /// Shows the HealthCheck response. /// diff --git a/Auth/LearningHub.Nhs.Auth/Helpers/InMemoryTicketStore.cs b/Auth/LearningHub.Nhs.Auth/Helpers/InMemoryTicketStore.cs new file mode 100644 index 0000000..1bfa669 --- /dev/null +++ b/Auth/LearningHub.Nhs.Auth/Helpers/InMemoryTicketStore.cs @@ -0,0 +1,104 @@ +namespace LearningHub.Nhs.Auth.Helpers +{ + using System; + using System.Collections.Concurrent; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication; + using Microsoft.AspNetCore.Authentication.Cookies; + + /// + /// Defines the . + /// + public class InMemoryTicketStore : ITicketStore + { + private readonly ConcurrentDictionary cache; + + /// + /// Initializes a new instance of the class. + /// The InMemoryTicketStore. + /// + /// the cache. + public InMemoryTicketStore(ConcurrentDictionary cache) + { + this.cache = cache; + } + + /// + /// The StoreAsync. + /// + /// The ticket. + /// The key. + public async Task StoreAsync(AuthenticationTicket ticket) + { + var ticketUserId = ticket.Principal.Claims.Where(c => c.Type == "sub") + .FirstOrDefault() + .Value; + var matchingAuthTicket = this.cache.Values.FirstOrDefault( + t => t.Principal.Claims.FirstOrDefault( + c => c.Type == "sub" + && c.Value == ticketUserId) != null); + if (matchingAuthTicket != null) + { + var cacheKey = this.cache.Where( + entry => entry.Value == matchingAuthTicket) + .Select(entry => entry.Key) + .FirstOrDefault(); + this.cache.TryRemove( + cacheKey, + out _); + } + + var key = Guid + .NewGuid() + .ToString(); + await this.RenewAsync( + key, + ticket); + return key; + } + + /// + /// The RenewAsync. + /// + /// The key. + /// The ticket. + /// The Task. + public Task RenewAsync( + string key, + AuthenticationTicket ticket) + { + this.cache.AddOrUpdate( + key, + ticket, + (_, _) => ticket); + return Task.CompletedTask; + } + + /// + /// The RetrieveAsync. + /// + /// The Key. + /// The Task. + public Task RetrieveAsync(string key) + { + this.cache.TryGetValue( + key, + out var ticket); + return Task.FromResult(ticket); + } + + /// + /// The RemoveAsync. + /// + /// The key. + /// The Task. + public Task RemoveAsync(string key) + { + this.cache.TryRemove( + key, + out _); + return Task.CompletedTask; + } + } + } diff --git a/Auth/LearningHub.Nhs.Auth/Interfaces/IMoodleApiService.cs b/Auth/LearningHub.Nhs.Auth/Interfaces/IMoodleApiService.cs new file mode 100644 index 0000000..81a3adc --- /dev/null +++ b/Auth/LearningHub.Nhs.Auth/Interfaces/IMoodleApiService.cs @@ -0,0 +1,17 @@ +namespace LearningHub.Nhs.Auth.Interfaces +{ + using System.Threading.Tasks; + + /// + /// IMoodleApiService. + /// + public interface IMoodleApiService + { + /// + /// GetResourcesAsync. + /// + /// The current User Id. + /// A representing the result of the asynchronous operation. + Task GetMoodleUserIdByUsernameAsync(int currentUserId); + } +} diff --git a/Auth/LearningHub.Nhs.Auth/Interfaces/IMoodleHttpClient.cs b/Auth/LearningHub.Nhs.Auth/Interfaces/IMoodleHttpClient.cs new file mode 100644 index 0000000..1d711b8 --- /dev/null +++ b/Auth/LearningHub.Nhs.Auth/Interfaces/IMoodleHttpClient.cs @@ -0,0 +1,23 @@ +namespace LearningHub.Nhs.Auth.Interfaces +{ + using System.Net.Http; + using System.Threading.Tasks; + + /// + /// The Moodle Http Client interface. + /// + public interface IMoodleHttpClient + { + /// + /// The get cient async. + /// + /// The . + Task GetClient(); + + /// + /// GetDefaultParameters. + /// + /// defaultParameters. + string GetDefaultParameters(); + } +} 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 f529539..2933aee 100644 --- a/Auth/LearningHub.Nhs.Auth/LearningHub.Nhs.Auth.csproj +++ b/Auth/LearningHub.Nhs.Auth/LearningHub.Nhs.Auth.csproj @@ -1,7 +1,7 @@  - net6.0 + net8.0 00EF27C2-ECB6-4E37-A6B6-58E4E6189D0E true x64 @@ -98,29 +98,29 @@ - - - - - - + + + + + + - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/Auth/LearningHub.Nhs.Auth/Models/MoodleUserResponseViewModel.cs b/Auth/LearningHub.Nhs.Auth/Models/MoodleUserResponseViewModel.cs new file mode 100644 index 0000000..fac745f --- /dev/null +++ b/Auth/LearningHub.Nhs.Auth/Models/MoodleUserResponseViewModel.cs @@ -0,0 +1,121 @@ +namespace LearningHub.Nhs.Auth.Models +{ + using System.Collections.Generic; + + /// + /// MoodleUserResponseViewModel. + /// + public class MoodleUserResponseViewModel + { + /// + /// Gets or sets the list of users. + /// + public List Users { get; set; } + + /// + /// Gets or sets the warnings. + /// + public List Warnings { get; set; } + + /// + /// MoodleUser. + /// + public class MoodleUser + { + /// + /// Gets or sets the user ID. + /// + public int Id { get; set; } + + /// + /// Gets or sets the username. + /// + public string Username { get; set; } + + /// + /// Gets or sets the first name. + /// + public string FirstName { get; set; } + + /// + /// Gets or sets the last name. + /// + public string LastName { get; set; } + + /// + /// Gets or sets the full name. + /// + public string FullName { get; set; } + + /// + /// Gets or sets the email. + /// + public string Email { get; set; } + + /// + /// Gets or sets the department. + /// + public string Department { get; set; } + + /// + /// Gets or sets the first access timestamp. + /// + public long FirstAccess { get; set; } + + /// + /// Gets or sets the last access timestamp. + /// + public long LastAccess { get; set; } + + /// + /// Gets or sets the authentication method. + /// + public string Auth { get; set; } + + /// + /// Gets or sets a value indicating whether the user is suspended. + /// + public bool Suspended { get; set; } + + /// + /// Gets or sets a value indicating whether the user is confirmed. + /// + public bool Confirmed { get; set; } + + /// + /// Gets or sets the language. + /// + public string Lang { get; set; } + + /// + /// Gets or sets the theme. + /// + public string Theme { get; set; } + + /// + /// Gets or sets the timezone. + /// + public string Timezone { get; set; } + + /// + /// Gets or sets the mail format. + /// + public int MailFormat { get; set; } + + /// + /// Gets or sets the forum tracking preference. + /// + public int TrackForums { get; set; } + + /// + /// Gets or sets the small profile image URL. + /// + public string ProfileImageUrlSmall { get; set; } + + /// + /// Gets or sets the profile image URL. + /// + public string ProfileImageUrl { get; set; } + } + } +} diff --git a/Auth/LearningHub.Nhs.Auth/NLog.config b/Auth/LearningHub.Nhs.Auth/NLog.config index eb67b60..a59da11 100644 --- a/Auth/LearningHub.Nhs.Auth/NLog.config +++ b/Auth/LearningHub.Nhs.Auth/NLog.config @@ -39,6 +39,10 @@ + + + + diff --git a/Auth/LearningHub.Nhs.Auth/ServiceCollectionExtension.cs b/Auth/LearningHub.Nhs.Auth/ServiceCollectionExtension.cs index 6aaf2f5..6268047 100644 --- a/Auth/LearningHub.Nhs.Auth/ServiceCollectionExtension.cs +++ b/Auth/LearningHub.Nhs.Auth/ServiceCollectionExtension.cs @@ -1,10 +1,12 @@ namespace LearningHub.Nhs.Auth { using System; + using System.Collections.Concurrent; using System.Security.Cryptography.X509Certificates; using Azure.Identity; using IdentityServer4; using LearningHub.Nhs.Auth.Configuration; + using LearningHub.Nhs.Auth.Helpers; using LearningHub.Nhs.Auth.Middleware; using LearningHub.Nhs.Caching; using LearningHub.Nhs.Models.Enums; @@ -70,7 +72,9 @@ public static void ConfigureServices(this IServiceCollection services, IConfigur { options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; - }).AddCookie().AddOpenIdConnect( + }) + .AddCookie() + .AddOpenIdConnect( "oidc_oa", options => { diff --git a/Auth/LearningHub.Nhs.Auth/Services/MoodleApiService.cs b/Auth/LearningHub.Nhs.Auth/Services/MoodleApiService.cs new file mode 100644 index 0000000..8edb52e --- /dev/null +++ b/Auth/LearningHub.Nhs.Auth/Services/MoodleApiService.cs @@ -0,0 +1,67 @@ +namespace LearningHub.Nhs.Auth.Services +{ + using System; + using System.Net.Http; + using System.Threading.Tasks; + using LearningHub.Nhs.Auth.Interfaces; + using LearningHub.Nhs.Auth.Models; + using Newtonsoft.Json; + + /// + /// MoodleApiService. + /// + public class MoodleApiService : IMoodleApiService + { + private readonly IMoodleHttpClient moodleHttpClient; + + /// + /// Initializes a new instance of the class. + /// + /// moodleHttpClient. + public MoodleApiService(IMoodleHttpClient moodleHttpClient) + { + this.moodleHttpClient = moodleHttpClient; + } + + /// + /// GetMoodleUserIdByUsernameAsync. + /// + /// current User Id. + /// UserId from Moodle. + public async Task GetMoodleUserIdByUsernameAsync(int currentUserId) + { + int moodleUserId = 0; + string additionalParameters = $"&criteria[0][key]=username&criteria[0][value]={currentUserId}"; + string defaultParameters = this.moodleHttpClient.GetDefaultParameters(); + + var client = await this.moodleHttpClient.GetClient(); + + string url = $"&wsfunction=core_user_get_users{additionalParameters}"; + + HttpResponseMessage response = await client.GetAsync("?" + defaultParameters + url); + if (response.IsSuccessStatusCode) + { + var result = response.Content.ReadAsStringAsync().Result; + var viewmodel = JsonConvert.DeserializeObject(result); + + if (viewmodel?.Users != null) + { + foreach (var user in viewmodel.Users) + { + if (user.Username == currentUserId.ToString()) + { + moodleUserId = user.Id; + } + } + } + } + else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || + response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new Exception("AccessDenied"); + } + + return moodleUserId; + } + } +} diff --git a/Auth/LearningHub.Nhs.Auth/Services/MoodleHttpClient.cs b/Auth/LearningHub.Nhs.Auth/Services/MoodleHttpClient.cs new file mode 100644 index 0000000..5538136 --- /dev/null +++ b/Auth/LearningHub.Nhs.Auth/Services/MoodleHttpClient.cs @@ -0,0 +1,87 @@ +namespace LearningHub.Nhs.Auth.Services +{ + using System; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Threading.Tasks; + using LearningHub.Nhs.Auth.Interfaces; + using Microsoft.Extensions.Configuration; + + /// + /// The moodle http client. + /// + public class MoodleHttpClient : IMoodleHttpClient, IDisposable + { + private readonly HttpClient httpClient = new (); + private bool initialised = false; + private string moodleAPIBaseUrl; + private string moodleAPIMoodleWSRestFormat; + private string moodleAPIWSToken; + + /// + /// Initializes a new instance of the class. + /// + /// httpClient. + /// config. + public MoodleHttpClient(HttpClient httpClient, IConfiguration config) + { + this.httpClient = httpClient; + this.moodleAPIBaseUrl = config["MoodleAPIConfig:BaseUrl"]; + this.moodleAPIMoodleWSRestFormat = config["MoodleAPIConfig:MoodleWSRestFormat"]; + this.moodleAPIWSToken = config["MoodleAPIConfig:WSToken"]; + } + + /// + /// The Get Client method. + /// + /// The . + public async Task GetClient() + { + this.Initialise(this.moodleAPIBaseUrl); + return this.httpClient; + } + + /// + /// GetDefaultParameters. + /// + /// defaultParameters. + public string GetDefaultParameters() + { + string defaultParameters = $"wstoken={this.moodleAPIWSToken}" + + $"&moodlewsrestformat={this.moodleAPIMoodleWSRestFormat}"; + + return defaultParameters; + } + + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// The dispoase. + /// + /// disposing. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + this.httpClient.Dispose(); + } + } + + private void Initialise(string httpClientUrl) + { + if (this.initialised == false) + { + this.httpClient.BaseAddress = new Uri(httpClientUrl); + this.httpClient.DefaultRequestHeaders.Accept.Clear(); + this.httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + this.initialised = true; + } + } + } +} 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/Auth/LearningHub.Nhs.Auth/UserServices/LearningHubProfileService.cs b/Auth/LearningHub.Nhs.Auth/UserServices/LearningHubProfileService.cs index 5a3a602..9175d60 100644 --- a/Auth/LearningHub.Nhs.Auth/UserServices/LearningHubProfileService.cs +++ b/Auth/LearningHub.Nhs.Auth/UserServices/LearningHubProfileService.cs @@ -6,6 +6,7 @@ using IdentityServer4.Extensions; using IdentityServer4.Models; using IdentityServer4.Services; + using LearningHub.Nhs.Auth.Configuration; using LearningHub.Nhs.Auth.Interfaces; using Microsoft.Extensions.Logging; @@ -14,19 +15,29 @@ /// public class LearningHubProfileService : IProfileService { + private readonly WebSettings webSettings; + /// /// Initializes a new instance of the class. /// /// /// The user service. /// + /// + /// The moodle api service. + /// /// /// The logger. /// - public LearningHubProfileService(IUserService userService, ILogger logger) + /// + /// The webSettings. + /// + public LearningHubProfileService(IUserService userService, IMoodleApiService moodleApiService, ILogger logger, WebSettings webSettings) { this.UserService = userService; + this.MoodleApiService = moodleApiService; this.Logger = logger; + this.webSettings = webSettings; } /// @@ -39,6 +50,11 @@ public LearningHubProfileService(IUserService userService, ILogger protected IUserService UserService { get; } + /// + /// Gets the moodle api service. + /// + protected IMoodleApiService MoodleApiService { get; } + /// /// The get profile data async. /// @@ -65,6 +81,12 @@ public async Task GetProfileDataAsync(ProfileDataRequestContext context) new Claim("elfh_userName", user.UserName), }; + if (this.webSettings.EnableMoodle) + { + var moodleUser = await this.MoodleApiService.GetMoodleUserIdByUsernameAsync(user.Id); + claims.Add(new Claim("preferred_username", moodleUser.ToString())); + } + if (context.Subject.HasClaim("openAthensUser", "true")) { claims.Add(new Claim("openAthensUser", "true")); 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..a0e908c --- /dev/null +++ b/Auth/LearningHub.Nhs.Auth/Views/Account/AlreadyActiveSession.cshtml @@ -0,0 +1,15 @@ +@{ + ViewData["Title"] = "Session already active"; +} +
+
+
+
+

@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 diff --git a/Auth/LearningHub.Nhs.Auth/Views/Account/LHLogin.cshtml b/Auth/LearningHub.Nhs.Auth/Views/Account/LHLogin.cshtml index 3aae2e5..91393ed 100644 --- a/Auth/LearningHub.Nhs.Auth/Views/Account/LHLogin.cshtml +++ b/Auth/LearningHub.Nhs.Auth/Views/Account/LHLogin.cshtml @@ -2,6 +2,7 @@ @{ ViewData["Title"] = "Login"; ViewData["Layout"] = "learninghub/_Layout"; + ViewData["DisableValidation"] = true; // OpenAthens url needs to makes sure that the return to WebUI first hits the authorisationrequired endpoint, // which contains it's own redirect to within the UI. // If a return url is specified we want to use it for the authorisationrequired original url @@ -54,7 +55,7 @@
@@ -54,12 +54,12 @@ id="@Model.DayId" name="@Model.DayId" value="@Model.DayValue" - type="number" - pattern="[0-9]*" + type="text" min="1" max="31" step="1" - inputmode="numeric" /> + inputmode="numeric" + aria-describedby="date-error" aria-invalid="false" />
@@ -69,12 +69,12 @@ id="@Model.MonthId" name="@Model.MonthId" value="@Model.MonthValue" - type="number" - pattern="[0-9]*" + type="text" min="1" max="12" step="1" - inputmode="numeric" /> + inputmode="numeric" + aria-describedby="date-error" aria-invalid="false" />
@@ -84,12 +84,12 @@ id="@Model.YearId" name="@Model.YearId" value="@Model.YearValue" - type="number" - pattern="[0-9]*" + type="text" min="1900" max="9999" step="1" - inputmode="numeric" /> + inputmode="numeric" + aria-describedby="date-error" aria-invalid="false" />
diff --git a/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/ErrorSummary/Default.cshtml b/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/ErrorSummary/Default.cshtml index 269b174..7f438d5 100644 --- a/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/ErrorSummary/Default.cshtml +++ b/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/ErrorSummary/Default.cshtml @@ -21,6 +21,10 @@ + } else { diff --git a/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/RadioList/Default.cshtml b/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/RadioList/Default.cshtml index 05eb266..eddfb33 100644 --- a/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/RadioList/Default.cshtml +++ b/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/RadioList/Default.cshtml @@ -9,7 +9,7 @@
-
+
@if (Model.IsPageHeading.GetValueOrDefault() == true) { @@ -44,7 +44,7 @@ @if (Model.Required && !Model.HasError) { -
+ } @@ -131,5 +131,4 @@
-
\ No newline at end of file diff --git a/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/TextInput/Default.cshtml b/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/TextInput/Default.cshtml index de13d0c..4cbdda0 100644 --- a/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/TextInput/Default.cshtml +++ b/Auth/LearningHub.Nhs.Auth/Views/Shared/Components/TextInput/Default.cshtml @@ -2,8 +2,8 @@ @using NHSUKViewComponents.Web.Helpers @model TextInputViewModel
-