diff --git a/DocIntel.AdminConsole/DocIntel.AdminConsole.csproj b/DocIntel.AdminConsole/DocIntel.AdminConsole.csproj index 44c62dd7..9c971976 100644 --- a/DocIntel.AdminConsole/DocIntel.AdminConsole.csproj +++ b/DocIntel.AdminConsole/DocIntel.AdminConsole.csproj @@ -14,6 +14,7 @@ + diff --git a/DocIntel.Core/DocIntel.Core.csproj b/DocIntel.Core/DocIntel.Core.csproj index 3808ba82..bddea1c5 100644 --- a/DocIntel.Core/DocIntel.Core.csproj +++ b/DocIntel.Core/DocIntel.Core.csproj @@ -23,6 +23,7 @@ + diff --git a/DocIntel.Tests/DocIntel.Tests.csproj b/DocIntel.Tests/DocIntel.Tests.csproj index 7185b7fe..fc1de8f9 100644 --- a/DocIntel.Tests/DocIntel.Tests.csproj +++ b/DocIntel.Tests/DocIntel.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/DocIntel.WebApp/Controllers/AccountController.cs b/DocIntel.WebApp/Controllers/AccountController.cs index dfb952d1..aa4844d3 100644 --- a/DocIntel.WebApp/Controllers/AccountController.cs +++ b/DocIntel.WebApp/Controllers/AccountController.cs @@ -19,6 +19,10 @@ using System.IO; using System.Linq; using System.Net; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; using System.Threading.Tasks; using DocIntel.Core.Authentication; using DocIntel.Core.Authorization; @@ -39,6 +43,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; @@ -113,9 +118,12 @@ public async Task Login(string returnUrl = null) { // Clear the existing external cookie to ensure a clean login process await HttpContext.SignOutAsync(); + var externalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + ViewData["ReturnUrl"] = returnUrl; return View(new SigninViewModel { + ExternalLogins = externalLogins, RememberMe = true }); } @@ -149,6 +157,7 @@ public async Task Login(SigninViewModel model, LogEvent.Formatter); ViewData["ReturnUrl"] = returnUrl; + model.ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); if (ModelState.IsValid) try @@ -1201,5 +1210,179 @@ public async Task VerifyAuthenticator(ConfigureTwoFactorAuthentic ModelState.AddModelError(nameof(model.Code), "Your code appears to be invalid."); return View(model); } + + [HttpPost] + [ValidateAntiForgeryToken] + [AllowAnonymous] + public async Task LoginExternal(string provider, string returnUrl = null) + { + // Request a redirect to the external login provider. + var redirectUrl = Url.Action("ExternalLogin", "Account", new { ReturnUrl = returnUrl }); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); + return new ChallengeResult(provider, properties); + } + + [AllowAnonymous] + public async Task ExternalLogin(string returnUrl = null, string remoteError = null) + { + returnUrl = returnUrl ?? Url.Content("~/"); + + if (remoteError != null) + { + TempData["ErrorMessage"] = $"Error from external provider: {remoteError}"; + return RedirectToLocal(returnUrl); + } + var info = await _signInManager.GetExternalLoginInfoAsync(); + if (info == null) + { + TempData["ErrorMessage"] = "Error loading external login information."; + return RedirectToLocal(returnUrl); + } + + // Sign in the user with this external login provider if the user already has a login. + var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true); + if (result.Succeeded) + { + var username = info.Principal.Identity.Name; + if (info.Principal.HasClaim(c => c.Type == "preferred_username")) + { + username = info.Principal.FindFirstValue("preferred_username"); + } + var currentUser = await _userManager.FindByNameAsync(username); + if (currentUser != null) + { + currentUser.LastLogin = DateTime.UtcNow; + await _userManager.UpdateAsync(currentUser); + } + _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", username, info.LoginProvider); + + return RedirectToLocal(returnUrl); + } + // if (result.IsLockedOut) + // { + // return RedirectToPage("./Lockout"); + // } + else + { + // If the user does not have an account, then ask the user to create an account. + ViewData["ReturnUrl"] = returnUrl; + var externalLoginViewModel = new ExternalLoginViewModel { + ProviderDisplayName = info.ProviderDisplayName, + }; + if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email)) + { + externalLoginViewModel.Email = info.Principal.FindFirstValue(ClaimTypes.Email); + } + if (info.Principal.HasClaim(c => c.Type == "preferred_username")) + { + externalLoginViewModel.UserName = info.Principal.FindFirstValue("preferred_username"); + } + return View(externalLoginViewModel); + } + } + + [HttpPost] + [AllowAnonymous] + public async Task ExternalLoginConfirmation(ExternalLoginViewModel model, string returnUrl = null) + { + returnUrl = returnUrl ?? Url.Content("/"); + // Get the information about the user from the external login provider + var info = await _signInManager.GetExternalLoginInfoAsync(); + if (info == null) + { + TempData["ErrorMessage"] = "Error loading external login information during confirmation."; + return RedirectToLocal(returnUrl); + } + + if (ModelState.IsValid) + { + try + { + var user = new AppUser { + RegistrationDate = DateTime.UtcNow, + Email = model.Email + }; + if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName)) + { + user.FirstName = info.Principal.FindFirstValue(ClaimTypes.GivenName); + } + if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Surname)) + { + user.LastName = info.Principal.FindFirstValue(ClaimTypes.Surname); + } + + // set username to supplied value if present, otherwise use email as username + user.UserName = info.Principal.HasClaim(c => c.Type == "preferred_username") + ? info.Principal.FindFirstValue("preferred_username") + : model.Email; + + var result = await _userManager.CreateAsync(user); + if (result.Succeeded) + { + result = await _userManager.AddLoginAsync(user, info); + if (result.Succeeded) + { + _logger.Log(LogLevel.Information, + EventIDs.RegistrationSuccessful, + new LogEvent($"User '{user.UserName}' created an account using '{info.LoginProvider}' provider.") + .AddUser(user) + .AddHttpContext(_accessor.HttpContext), + null, + LogEvent.Formatter); + + if (_userManager.Options.SignIn.RequireConfirmedAccount) + { + var userId = await _userManager.GetUserIdAsync(user); + var tokenConfirmation = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var callbackConfirmationUrl = Url.Action( + "ConfirmEmail", "Account", + new { userId = userId, code = tokenConfirmation }, + protocol: Request.Scheme); + await _emailSender.SendEmailConfirmation(user, callbackConfirmationUrl); + + return View("EmailConfirmation"); + } + await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider); + return LocalRedirect(returnUrl); + } + } + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + _logger.Log(LogLevel.Warning, + EventIDs.RegistrationFailed, + new LogEvent($"Registration failed for user '{model.Email}'.") + .AddHttpContext(_accessor.HttpContext), + null, + LogEvent.Formatter); + } + catch (Exception e) + { + _logger.Log(LogLevel.Error, + EventIDs.RegistrationError, + new LogEvent( + $"An error occured while registering user '{model.Email}' (Exception: {e.Message}).") + .AddException(e) + .AddProperty("user.email", model.Email) + .AddHttpContext(_accessor.HttpContext) + .AddException(e), + e, + LogEvent.Formatter); + + TempData["ErrorMessage"] = "Something bad happened while creating your account..."; + model.ProviderDisplayName = info.ProviderDisplayName; + + return View(model); + } + } + + var externalLoginViewModel = new ExternalLoginViewModel { + ProviderDisplayName = info.ProviderDisplayName, + }; + ViewData["ReturnUrl"] = returnUrl; + return View(externalLoginViewModel); + } } } \ No newline at end of file diff --git a/DocIntel.WebApp/DocIntel.WebApp.csproj b/DocIntel.WebApp/DocIntel.WebApp.csproj index 8094a915..a1098b29 100644 --- a/DocIntel.WebApp/DocIntel.WebApp.csproj +++ b/DocIntel.WebApp/DocIntel.WebApp.csproj @@ -16,6 +16,7 @@ + @@ -99,6 +100,7 @@ + diff --git a/DocIntel.WebApp/Properties/launchSettings.json b/DocIntel.WebApp/Properties/launchSettings.json index 29f9c197..224a048a 100644 --- a/DocIntel.WebApp/Properties/launchSettings.json +++ b/DocIntel.WebApp/Properties/launchSettings.json @@ -22,6 +22,15 @@ "ASPNETCORE_ENVIRONMENT": "Development" }, "applicationUrl": "http://localhost:5001/" + }, + "DocIntelhttps": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7033;http://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } } } } diff --git a/DocIntel.WebApp/Startup.cs b/DocIntel.WebApp/Startup.cs index e76630b1..23a29599 100644 --- a/DocIntel.WebApp/Startup.cs +++ b/DocIntel.WebApp/Startup.cs @@ -42,6 +42,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -59,6 +60,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; @@ -187,6 +189,22 @@ public virtual void ConfigureServices(IServiceCollection services) options.SlidingExpiration = true; options.ForwardAuthenticate = "Identity.Application"; }); + + var openIdSection = Configuration.GetSection("Authentication:OIDC"); + if (openIdSection.Exists()) + { + services.AddAuthentication() + .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, + openIdSection.GetValue("DisplayName", OpenIdConnectDefaults.DisplayName), + options => + { + options.Authority = openIdSection["Authority"]; + options.ClientId = openIdSection["ClientId"]; + options.ClientSecret = openIdSection["ClientSecret"]; + options.ResponseType = OpenIdConnectResponseType.Code; + options.SaveTokens = true; + }); + } services.AddAuthorization(options => { options.DefaultPolicy = policy; }); services.AddScoped, AppUserClaimsPrincipalFactory>(); diff --git a/DocIntel.WebApp/ViewModels/Account/ExternalLoginViewModel.cs b/DocIntel.WebApp/ViewModels/Account/ExternalLoginViewModel.cs new file mode 100644 index 00000000..bd3f639c --- /dev/null +++ b/DocIntel.WebApp/ViewModels/Account/ExternalLoginViewModel.cs @@ -0,0 +1,33 @@ +/* DocIntel + * Copyright (C) 2018-2023 Belgian Defense, Antoine Cailliau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . +*/ + +using System.ComponentModel.DataAnnotations; + +namespace DocIntel.WebApp.ViewModels.Account +{ + public class ExternalLoginViewModel + { + public string ProviderDisplayName { get; set; } + + [Required] + [EmailAddress] + public string Email { get; set; } + + [Display(Name = "User name")] + public string UserName { get; set; } + } +} \ No newline at end of file diff --git a/DocIntel.WebApp/ViewModels/Account/SigninViewModel.cs b/DocIntel.WebApp/ViewModels/Account/SigninViewModel.cs index 469a1df4..bbbbf49a 100644 --- a/DocIntel.WebApp/ViewModels/Account/SigninViewModel.cs +++ b/DocIntel.WebApp/ViewModels/Account/SigninViewModel.cs @@ -15,7 +15,9 @@ * along with this program. If not, see . */ +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authentication; namespace DocIntel.WebApp.ViewModels.Account { @@ -31,5 +33,7 @@ public class SigninViewModel public string Password { get; set; } [Display(Name = "Remember me?")] public bool RememberMe { get; set; } + + public IList ExternalLogins { get; set; } } } \ No newline at end of file diff --git a/DocIntel.WebApp/Views/Account/ExternalLogin.cshtml b/DocIntel.WebApp/Views/Account/ExternalLogin.cshtml new file mode 100644 index 00000000..ab65b530 --- /dev/null +++ b/DocIntel.WebApp/Views/Account/ExternalLogin.cshtml @@ -0,0 +1,46 @@ +@using DocIntel.Core.Settings +@using Microsoft.AspNetCore.Mvc.TagHelpers +@model DocIntel.WebApp.ViewModels.Account.ExternalLoginViewModel +@{ + Layout = "_PublicLayout"; + ViewData["Title"] = "Register"; +} + +

@ViewData["Title"]

+

Associate your @Model.ProviderDisplayName account.

+
+ +

+ You've successfully authenticated with @Model.ProviderDisplayName. + Please enter an email address for this site below and click the Register button to finish + logging in. +

+ +
+
+
+ @if (TempData.ContainsKey("ErrorMessage")) + { + + } + +
+ + + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/DocIntel.WebApp/Views/Account/Login.cshtml b/DocIntel.WebApp/Views/Account/Login.cshtml index 9a0fa869..582c585c 100644 --- a/DocIntel.WebApp/Views/Account/Login.cshtml +++ b/DocIntel.WebApp/Views/Account/Login.cshtml @@ -56,6 +56,28 @@ + @{ + if ((Model.ExternalLogins?.Count ?? 0) != 0) + { +
+
+

Use another service to log in.

+
+
+
+
+
+ @foreach (var provider in Model.ExternalLogins!) + { + + } +
+
+
+
+
+ } + } diff --git a/README.md b/README.md index dc17b9df..2ab22886 100644 --- a/README.md +++ b/README.md @@ -32,4 +32,25 @@ However, you would not be able to log in the platform. You need to create an acc dotnet /cli/DocIntel.AdminConsole.dll \ user role --username admin --role administrator -You can now login on http://localhost:5005. \ No newline at end of file +You can now login on http://localhost:5005. + +## OIDC Support + +Login via OIDC is supported, you have to create a client in your OIDC server and +configure DocIntel like in the example configuration file found in [conf/appsettings.json.oidc.example](conf/appsettings.json.oidc.example) + +For OIDC to work when running the app behind a reverse proxy, all requests need to be https. +This can be achieved by setting an env variable for the webapp service in docker-compose (see also conf/docker-compose.yml.example) +which forwards the https scheme header so that generated URLs by DocIntel also contain the https scheme. + +```yaml + webapp: + image: "docintelapp/webapp" + container_name: docintel-dev-webapp + ports: + - 5005:80 + environment: + - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true +``` + +More information can be found [here](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-7.0#forward-the-scheme-for-linux-and-non-iis-reverse-proxies) \ No newline at end of file diff --git a/conf/appsettings.json.oidc.example b/conf/appsettings.json.oidc.example new file mode 100644 index 00000000..466aa5fb --- /dev/null +++ b/conf/appsettings.json.oidc.example @@ -0,0 +1,57 @@ +{ + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Trace" + } + }, + + "ApplicationName" : "DocIntel", + "ApplicationBaseURL" : "http://localhost:5005/", + + "OpenRegistration": true, + "Authentication": { + "OIDC": { + "Authority": "https://keycloak.example.com/auth/realms/example-realm", + "ClientId": "docintel", + "ClientSecret": "my_oidc_secert", + "DisplayName": "My Example SSO" + } + }, + + "DocFolder": "/files", + "DocumentPrefix" : "DI", + "StaticFiles": "./wwwroot/", + "LockFolder": "/lock", + + "ConnectionStrings": { + "DefaultConnection": "User ID=_POSTGRES_USER_;Password=_POSTGRES_PW_;Host=_POSTGRES_HOST_;Port=_POSTGRES_PORT_;Database=_POSTGRES_DB_;Pooling=true;" + }, + + "Jwt": { + "Key": "ThisismySecretKey", + "Issuer": "localhost", + "Audience": "test" + }, + + "Email": { + "EmailEnabled": false + }, + + "Synapse": { + "URL": "_SYNAPSE_URL_", + "UserName": "_SYNAPSE_USER_", + "Password": "_SYNAPSE_PW_" + }, + + "RabbitMQ": { + "Host": "_RABBITMQ_HOST_", + "VirtualHost": "_RABBITMQ_VHOST_", + "Username": "_RABBITMQ_USER_", + "Password": "_RABBITMQ_PW_" + }, + + "Solr": { + "Uri": "_SOLR_URL_" + } +} diff --git a/conf/docker-compose.yml.example b/conf/docker-compose.yml.example index c5eef5e7..1093093a 100644 --- a/conf/docker-compose.yml.example +++ b/conf/docker-compose.yml.example @@ -166,6 +166,8 @@ services: container_name: docintel-dev-webapp ports: - 5005:80 + environment: + - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true volumes: - _DOCINTEL_CONFIG_:/config - _DOCINTEL_DATA_/files/:/files