diff --git a/LANCommander.Server.Services/Models/Settings.cs b/LANCommander.Server.Services/Models/Settings.cs index 5ac4729f..f1b8075f 100644 --- a/LANCommander.Server.Services/Models/Settings.cs +++ b/LANCommander.Server.Services/Models/Settings.cs @@ -80,7 +80,10 @@ namespace LANCommander.Server.Services.Models public class AuthenticationProvider { public string Name { get; set; } + public string Slug { get; set; } public AuthenticationProviderType Type { get; set; } = AuthenticationProviderType.OAuth2; + public string Color { get; set; } + public string Icon { get; set; } public string Documentation { get; set; } public string ClientId { get; set; } public string ClientSecret { get; set; } @@ -90,18 +93,6 @@ namespace LANCommander.Server.Services.Models public string UserInfoEndpoint { get; set; } public IEnumerable Scopes { get; set; } = new List(); public IEnumerable ClaimMappings { get; set; } = new List(); - - public string GetSlug() - { - var slug = Name.ToLower(); - - slug = Regex.Replace(slug, @"[^a-z0-9\s-]", ""); - slug = Regex.Replace(slug, @"\s+", " ").Trim(); - slug = slug.Substring(0, slug.Length <= 45 ? slug.Length : 45).Trim(); - slug = Regex.Replace(slug, @"\s", "-"); - - return slug; - } } public class ClaimMapping diff --git a/LANCommander.Server/Areas/Identity/Pages/Account/Login.cshtml b/LANCommander.Server/Areas/Identity/Pages/Account/Login.cshtml index 9c61e66e..4588d744 100644 --- a/LANCommander.Server/Areas/Identity/Pages/Account/Login.cshtml +++ b/LANCommander.Server/Areas/Identity/Pages/Account/Login.cshtml @@ -3,13 +3,13 @@ @using LANCommander.Server.Models @using LANCommander.Server.Services @using LANCommander.Server.Services.Models -@model LoginModel +@model LANCommander.Server.Areas.Identity.Pages.Account.LoginModel @{ Layout = "/UI/Views/Shared/_LayoutBasic.cshtml"; } @{ ViewData["Title"] = "Log in"; - var providers = await HttpContext.GetExternalProvidersAsync(); + var providers = HttpContext.GetExternalProviders(); }
@@ -118,11 +118,11 @@ @foreach (var provider in providers) {
- +
} diff --git a/LANCommander.Server/Controllers/AccountController.cs b/LANCommander.Server/Controllers/AccountController.cs new file mode 100644 index 00000000..0b28e72c --- /dev/null +++ b/LANCommander.Server/Controllers/AccountController.cs @@ -0,0 +1,19 @@ +using LANCommander.Server.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LANCommander.Server.Controllers; + +public class AccountController : BaseController +{ + public AccountController(ILogger logger) : base(logger) + { + } + + [HttpGet("/SignInOAuth")] + public async Task SignInOAuth() + { + var authenticationProviders = await AuthenticationService.GetAuthenticationProviderTemplatesAsync(); + + return Ok(); + } +} \ No newline at end of file diff --git a/LANCommander.Server/Extensions/AuthenticationBuilderExtensions.cs b/LANCommander.Server/Extensions/AuthenticationBuilderExtensions.cs index 60f69b77..c6068280 100644 --- a/LANCommander.Server/Extensions/AuthenticationBuilderExtensions.cs +++ b/LANCommander.Server/Extensions/AuthenticationBuilderExtensions.cs @@ -1,7 +1,14 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using LANCommander.Server.Data.Models; +using LANCommander.Server.Services; using LANCommander.Server.Services.Models; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; +using AuthenticationService = LANCommander.Server.Services.AuthenticationService; namespace LANCommander.Server.Extensions; @@ -9,9 +16,10 @@ public static class AuthenticationBuilderExtensions { public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder authBuilder, AuthenticationProvider authenticationProvider) { - var slug = authenticationProvider.GetSlug(); + if (String.IsNullOrWhiteSpace(authenticationProvider.Slug)) + return authBuilder; - return authBuilder.AddOpenIdConnect(slug, authenticationProvider.Name, options => + return authBuilder.AddOpenIdConnect(authenticationProvider.Slug, authenticationProvider.Name, options => { options.ClientId = authenticationProvider.ClientId; options.ClientSecret = authenticationProvider.ClientSecret; @@ -30,8 +38,8 @@ public static class AuthenticationBuilderExtensions }; // Callbacks for middleware to properly correlate - options.CallbackPath = new PathString($"/signin-oidc-{slug}"); - options.SignedOutCallbackPath = new PathString($"/signout-oidc-{slug}"); + options.CallbackPath = new PathString($"/SignInOIDC"); + options.SignedOutCallbackPath = new PathString($"/SignOutOIDC"); foreach (var scope in authenticationProvider.Scopes) { @@ -49,13 +57,14 @@ public static class AuthenticationBuilderExtensions public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder authBuilder, AuthenticationProvider authenticationProvider) { - var slug = authenticationProvider.GetSlug(); + if (String.IsNullOrWhiteSpace(authenticationProvider.Slug)) + return authBuilder; - return authBuilder.AddOAuth(slug, authenticationProvider.Name, options => + return authBuilder.AddOAuth(authenticationProvider.Slug, authenticationProvider.Name, options => { options.ClientId = authenticationProvider.ClientId; options.ClientSecret = authenticationProvider.ClientSecret; - options.CallbackPath = new PathString($"/signin-oauth-{slug}"); + options.CallbackPath = new PathString($"/SignInOAuth"); options.AuthorizationEndpoint = authenticationProvider.AuthorizationEndpoint; options.TokenEndpoint = authenticationProvider.TokenEndpoint; @@ -74,26 +83,35 @@ public static class AuthenticationBuilderExtensions options.ClaimActions.MapJsonKey(claimMapping.Name, claimMapping.Value); } - options.Events.OnTicketReceived = async context => - { - - }; - - // Retrieve user information - /*options.Events.OnCreatingTicket = async context => + options.Events.OnCreatingTicket = async context => { var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken); var response = await context.Backchannel.SendAsync(request); + if (!response.IsSuccessStatusCode) - { throw new HttpRequestException($"An error occurred while retrieving the user profile: {response.StatusCode}"); - } - var user = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - context.RunClaimActions(user.RootElement); - };*/ + var oauthUser = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + + context.Identity.AddClaim(new Claim("Provider", authenticationProvider.Name)); + + context.RunClaimActions(oauthUser.RootElement); + + var signInManager = context.HttpContext.RequestServices.GetService>(); + var userService = context.HttpContext.RequestServices.GetService(); + var userCustomFieldService = context.HttpContext.RequestServices.GetService(); + + var customField = await userCustomFieldService.FirstOrDefaultAsync(cf => cf.Name == $"ExternalId/{authenticationProvider.Slug}" && cf.Value == context.Identity.Name); + var user = await userService.GetAsync(customField.UserId.GetValueOrDefault()); + + await signInManager.SignInAsync(user, true); + + context.Response.Redirect("/"); + await context.Response.CompleteAsync(); + }; }); } } \ No newline at end of file diff --git a/LANCommander.Server/Extensions/HttpContextExtensions.cs b/LANCommander.Server/Extensions/HttpContextExtensions.cs index 0d63e8b8..93bd1b3a 100644 --- a/LANCommander.Server/Extensions/HttpContextExtensions.cs +++ b/LANCommander.Server/Extensions/HttpContextExtensions.cs @@ -1,27 +1,21 @@ -using Microsoft.AspNetCore.Authentication; +using LANCommander.Server.Services; +using LANCommander.Server.Services.Models; +using Microsoft.AspNetCore.Authentication; namespace LANCommander.Server.Extensions { public static class HttpContextExtensions { - public static async Task GetExternalProvidersAsync(this HttpContext context) + public static IEnumerable GetExternalProviders(this HttpContext context) { - ArgumentNullException.ThrowIfNull(context); - - var schemes = context.RequestServices.GetRequiredService(); - - return (from scheme in await schemes.GetAllSchemesAsync() - where !string.IsNullOrEmpty(scheme.DisplayName) - select scheme).ToArray(); + var settings = SettingService.GetSettings(); + + return settings.Authentication.AuthenticationProviders; } public static async Task IsProviderSupportedAsync(this HttpContext context, string provider) { - ArgumentNullException.ThrowIfNull(context); - - return (from scheme in await context.GetExternalProvidersAsync() - where string.Equals(scheme.Name, provider, StringComparison.OrdinalIgnoreCase) - select scheme).Any(); + return true; } } } diff --git a/LANCommander.Server/LANCommander.Server.csproj b/LANCommander.Server/LANCommander.Server.csproj index 2f936fbc..e0c466e9 100644 --- a/LANCommander.Server/LANCommander.Server.csproj +++ b/LANCommander.Server/LANCommander.Server.csproj @@ -77,6 +77,7 @@ + diff --git a/LANCommander.Server/Models/LoginModel.cs b/LANCommander.Server/Models/LoginModel.cs new file mode 100644 index 00000000..b2e4712e --- /dev/null +++ b/LANCommander.Server/Models/LoginModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace LANCommander.Server.Models; + +public class LoginModel +{ + [Required] + [DataType(DataType.Text)] + [Display(Name = "User Name")] + public string Username { get; set; } + + [Required] + [DataType(DataType.Password)] + public string Password { get; set; } + + // public string ReturnUrl { get; set; } + + [Display(Name = "Remember me?")] + public bool RememberMe { get; set; } +} \ No newline at end of file diff --git a/LANCommander.Server/UI/Pages/Account/Login.cshtml b/LANCommander.Server/UI/Pages/Account/Login.cshtml new file mode 100644 index 00000000..03b77a0c --- /dev/null +++ b/LANCommander.Server/UI/Pages/Account/Login.cshtml @@ -0,0 +1,146 @@ +@page "/Login" +@using LANCommander.Server.Extensions +@using LANCommander.Server.Models +@using LANCommander.Server.Services +@using LANCommander.Server.Services.Models +@model LANCommander.Server.UI.Pages.Account.LoginModel +@{ Layout = "/UI/Views/Shared/_LayoutBasic.cshtml"; } + +@{ + ViewData["Title"] = "Log in"; + + var providers = HttpContext.GetExternalProviders(); +} + +
+
+ +
+ @switch (SettingService.GetSettings().Theme) + { + case LANCommanderTheme.Light: + + break; + + case LANCommanderTheme.Dark: + + break; + } +
+ + @foreach (var error in ModelState.SelectMany(x => x.Value.Errors)) + { +
+
+
@error.ErrorMessage
+
+
+ } + +
+
+
+
Login
+
+
+ +
+ @Html.AntiForgeryToken() + +
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
+ + @if (providers != null && providers.Count() > 0) + { + + + Or + + + + @foreach (var provider in providers) + { +
+ + + + +
+ } + } + +
+ Don't have account yet? Register +
+
+
\ No newline at end of file diff --git a/LANCommander.Server/UI/Pages/Account/Login.cshtml.cs b/LANCommander.Server/UI/Pages/Account/Login.cshtml.cs new file mode 100644 index 00000000..5618810f --- /dev/null +++ b/LANCommander.Server/UI/Pages/Account/Login.cshtml.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using LANCommander.Server.Data.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using LANCommander.Server.Services; +using LANCommander.Server.Extensions; +using LANCommander.Server.Data; + +namespace LANCommander.Server.UI.Pages.Account +{ + public class LoginModel : PageModel + { + private readonly SignInManager SignInManager; + private readonly UserService UserService; + private readonly RoleService RoleService; + private readonly ILogger Logger; + + public LoginModel( + SignInManager signInManager, + UserService userService, + RoleService roleService, + ILogger logger) + { + SignInManager = signInManager; + UserService = userService; + RoleService = roleService; + Logger = logger; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public Models.LoginModel Model { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public IList ExternalLogins { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public string ReturnUrl { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string ErrorMessage { get; set; } + + public async Task OnGetAsync(string returnUrl = null) + { + if (!string.IsNullOrEmpty(ErrorMessage)) + { + ModelState.AddModelError(string.Empty, ErrorMessage); + } + + returnUrl ??= Url.Content("~/"); + + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + ExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + + ReturnUrl = returnUrl; + + if (DatabaseContext.Provider == Data.Enums.DatabaseProvider.Unknown) + return Redirect("/FirstTimeSetup"); + + var administrators = await RoleService.GetUsersAsync(RoleService.AdministratorRoleName); + + if (administrators == null || !administrators.Any()) + return Redirect("/FirstTimeSetup"); + + return Page(); + } + + public async Task OnPostAsync(string returnUrl = null, string provider = null) + { + returnUrl ??= Url.Content("~/"); + + ExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + + if (!String.IsNullOrWhiteSpace(provider) && await HttpContext.IsProviderSupportedAsync(provider)) + { + return Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, provider); + } + + if (ModelState.IsValid) + { + var settings = SettingService.GetSettings(); + + if (settings.Authentication.RequireApproval) + { + var user = await UserService.GetAsync(Model.Username); + + if (user != null && !user.Approved && !(await UserService.IsInRoleAsync(user.UserName, RoleService.AdministratorRoleName))) + { + ModelState.AddModelError(string.Empty, "Your account must be approved by an administrator."); + return Page(); + } + } + + // This doesn't count login failures towards account lockout + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + var result = await SignInManager.PasswordSignInAsync(Model.Username, Model.Password, Model.RememberMe, lockoutOnFailure: false); + if (result.Succeeded) + { + Logger.LogInformation("User logged in."); + return LocalRedirect(returnUrl); + } + if (result.RequiresTwoFactor) + { + return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Model.RememberMe }); + } + if (result.IsLockedOut) + { + Logger.LogWarning("User account locked out."); + return RedirectToPage("./Lockout"); + } + else + { + ModelState.AddModelError(string.Empty, "Invalid login attempt."); + return Page(); + } + } + + // If we got this far, something failed, redisplay form + return Page(); + } + } +} diff --git a/LANCommander.Server/UI/Pages/Account/_ViewImports.cshtml b/LANCommander.Server/UI/Pages/Account/_ViewImports.cshtml new file mode 100644 index 00000000..041ade33 --- /dev/null +++ b/LANCommander.Server/UI/Pages/Account/_ViewImports.cshtml @@ -0,0 +1,6 @@ +@using Microsoft.AspNetCore.Identity +@using LANCommander.Server.Areas.Identity +@using LANCommander.Server.Areas.Identity.Pages +@using LANCommander.Server.Data.Models +@using LANCommander.Server.UI.Components +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/LANCommander.Server/UI/Pages/Settings/Authentication/Index.razor b/LANCommander.Server/UI/Pages/Settings/Authentication/Index.razor index 54b01940..5ed99c35 100644 --- a/LANCommander.Server/UI/Pages/Settings/Authentication/Index.razor +++ b/LANCommander.Server/UI/Pages/Settings/Authentication/Index.razor @@ -1,4 +1,5 @@ @page "/Settings/Authentication" +@using CaseConverter @using LANCommander.Server.Models; @using LANCommander.Server.UI.Pages.Settings.Authentication.Components @inject IMessageService MessageService @@ -60,6 +61,12 @@ { try { + foreach (var authenticationProvider in Settings.Authentication.AuthenticationProviders) + { + if (String.IsNullOrWhiteSpace(authenticationProvider.Slug)) + authenticationProvider.Slug = authenticationProvider.Name.ToPascalCase(); + } + SettingService.SaveSettings(Settings); MessageService.Success("Settings saved!"); } diff --git a/LANCommander.Server/UI/_Imports.razor b/LANCommander.Server/UI/_Imports.razor index bea8f99e..1a3c3975 100644 --- a/LANCommander.Server/UI/_Imports.razor +++ b/LANCommander.Server/UI/_Imports.razor @@ -4,6 +4,7 @@ @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Identity @using Microsoft.Extensions.Logging @using Microsoft.JSInterop diff --git a/LANCommander.Server/appsettings.Development.json b/LANCommander.Server/appsettings.Development.json index 0c208ae9..f8063ef1 100644 --- a/LANCommander.Server/appsettings.Development.json +++ b/LANCommander.Server/appsettings.Development.json @@ -4,5 +4,6 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - } + }, + "DetailedErrors": true }