Map external user auths to users via custom fields

This commit is contained in:
Pat Hartl 2024-12-29 18:58:01 -06:00
parent 98c0d276b0
commit bf9ca2fc2d
13 changed files with 404 additions and 50 deletions

View file

@ -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<string> Scopes { get; set; } = new List<string>();
public IEnumerable<ClaimMapping> ClaimMappings { get; set; } = new List<ClaimMapping>();
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

View file

@ -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();
}
<div class="ant-row ant-row-middle ant-row-space-around" style="min-height: 100vh; margin-top: -24px;">
@ -118,11 +118,11 @@
@foreach (var provider in providers)
{
<form method="post">
<input type="hidden" name="Provider" value="@provider.Name" />
<input type="hidden" name="Provider" value="@provider.Slug" />
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" />
<button class="ant-btn ant-btn-primary ant-btn-block" type="submit">
<span>Sign in using @provider.DisplayName</span>
<span>Sign in using @provider.Name</span>
</button>
</form>
}

View file

@ -0,0 +1,19 @@
using LANCommander.Server.Services;
using Microsoft.AspNetCore.Mvc;
namespace LANCommander.Server.Controllers;
public class AccountController : BaseController
{
public AccountController(ILogger<AccountController> logger) : base(logger)
{
}
[HttpGet("/SignInOAuth")]
public async Task<IActionResult> SignInOAuth()
{
var authenticationProviders = await AuthenticationService.GetAuthenticationProviderTemplatesAsync();
return Ok();
}
}

View file

@ -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<SignInManager<User>>();
var userService = context.HttpContext.RequestServices.GetService<UserService>();
var userCustomFieldService = context.HttpContext.RequestServices.GetService<UserCustomFieldService>();
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();
};
});
}
}

View file

@ -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<AuthenticationScheme[]> GetExternalProvidersAsync(this HttpContext context)
public static IEnumerable<AuthenticationProvider> GetExternalProviders(this HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
var schemes = context.RequestServices.GetRequiredService<IAuthenticationSchemeProvider>();
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<bool> 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;
}
}
}

View file

@ -77,6 +77,7 @@
<ItemGroup>
<PackageReference Include="AntDesign.Charts" Version="0.5.6" />
<PackageReference Include="ByteSize" Version="2.1.2" />
<PackageReference Include="CaseConverter" Version="2.0.1" />
<PackageReference Include="CoreRCON" Version="5.0.5" />
<PackageReference Include="craftersmine.SteamGridDB.Net" Version="1.1.7" />
<PackageReference Include="Crc32.NET" Version="1.2.0" />

View file

@ -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; }
}

View file

@ -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();
}
<div class="ant-row ant-row-middle ant-row-space-around" style="min-height: 100vh; margin-top: -24px;">
<div class="ant-col ant-col-xs-24 ant-col-md-10">
<div style="text-align: center; margin-bottom: 24px;">
@switch (SettingService.GetSettings().Theme)
{
case LANCommanderTheme.Light:
<img src="~/static/logo.svg" />
break;
case LANCommanderTheme.Dark:
<img src="~/static/logo-dark.svg" />
break;
}
</div>
@foreach (var error in ModelState.SelectMany(x => x.Value.Errors))
{
<div data-show="true" class="ant-alert ant-alert-error ant-alert-no-icon" style="margin-bottom: 16px">
<div class="ant-alert-content">
<div class="ant-alert-message">@error.ErrorMessage</div>
</div>
</div>
}
<div class="ant-card ant-card-bordered">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Login</div>
</div>
</div>
<form id="account" method="post" class="ant-card-body" autocomplete="off">
@Html.AntiForgeryToken()
<div class="ant-form ant-form-vertical">
<div class="ant-form-item">
<div class="ant-form-item-row ant-row">
<div class="ant-form-item-label ant-col">
<label asp-for="Model.Username" class="form-label"></label>
</div>
<div class="ant-form-item-control ant-col">
<div class="ant-form-item-control-input">
<div class="ant-form-item-control-input-content">
<input asp-for="Model.Username" class="ant-input" autocomplete="username" aria-required="true"/>
</div>
</div>
</div>
</div>
</div>
<div class="ant-form-item">
<div class="ant-form-item-row ant-row">
<div class="ant-form-item-label ant-col">
<label asp-for="Model.Password" class="form-label"></label>
</div>
<div class="ant-form-item-control ant-col">
<div class="ant-form-item-control-input">
<div class="ant-form-item-control-input-content">
<input asp-for="Model.Password" class="ant-input" autocomplete="current-password" aria-required="true"/>
</div>
</div>
</div>
</div>
</div>
<div class="ant-form-item">
<div class="ant-form-item-row ant-row">
<div class="ant-form-item-control ant-col">
<div class="ant-form-item-control-input">
<div class="ant-form-item-control-input-content">
<label class="ant-checkbox-wrapper">
<span class="ant-checkbox">
<input class="ant-checkbox-input" asp-for="Model.RememberMe"/>
<span class="ant-checkbox-inner"></span>
</span>
<span>
@Html.DisplayNameFor(m => m.Model.RememberMe)
</span>
</label>
</div>
</div>
</div>
</div>
</div>
<div class="ant-form-item" style="margin-bottom: 0;">
<div class="ant-form-item-row ant-row">
<button id="login-submit" type="submit" class="ant-btn ant-btn-primary ant-btn-block">Sign in</button>
</div>
</div>
</div>
</form>
</div>
@if (providers != null && providers.Count() > 0)
{
<span class="ant-divider ant-divider-horizontal ant-divider-with-text ant-divider-with-text-center">
<span class="ant-divider-inner-text">
Or
</span>
</span>
@foreach (var provider in providers)
{
<form method="post">
<input type="hidden" name="Provider" value="@provider.Slug" />
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" />
<button class="ant-btn ant-btn-primary ant-btn-block" type="submit">
@if (!String.IsNullOrWhiteSpace(provider.Icon))
{
<span class="anticon" role="img">
<svg class="bi" width="32" height="32" fill="currentColor">
<use xlink:href="bootstrap-icons.svg#@provider.Icon"/>
</svg>
</span>
}
<span>Sign in using @provider.Name</span>
</button>
</form>
}
}
<div style="text-align: center; margin-top: 16px;">
Don't have account yet? <a asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl" tabindex="-1">Register</a>
</div>
</div>
</div>

View file

@ -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<User> SignInManager;
private readonly UserService UserService;
private readonly RoleService RoleService;
private readonly ILogger<LoginModel> Logger;
public LoginModel(
SignInManager<User> signInManager,
UserService userService,
RoleService roleService,
ILogger<LoginModel> logger)
{
SignInManager = signInManager;
UserService = userService;
RoleService = roleService;
Logger = logger;
}
/// <summary>
/// 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.
/// </summary>
[BindProperty]
public Models.LoginModel Model { get; set; }
/// <summary>
/// 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.
/// </summary>
public IList<AuthenticationScheme> ExternalLogins { get; set; }
/// <summary>
/// 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.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// 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.
/// </summary>
[TempData]
public string ErrorMessage { get; set; }
public async Task<IActionResult> 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<IActionResult> 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();
}
}
}

View file

@ -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

View file

@ -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!");
}

View file

@ -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

View file

@ -4,5 +4,6 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
},
"DetailedErrors": true
}