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.
+
+ }
+ }
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