Add setting to auto redirect to external provider

When enabled, AutoRedirectToProvider will switch authentication challenges to redirect to the external auth provider instead of displaying the password login form. If more than one external provider is configured, a minimal external auth provider login page will be shown.

Ref #410
This commit is contained in:
Pat Hartl 2026-06-21 01:58:27 -05:00
parent ca7601e218
commit 0f03461f53
8 changed files with 221 additions and 34 deletions

View file

@ -6,6 +6,7 @@ public class AuthenticationSettings
{
public bool RequireApproval { get; set; } = false;
public bool AllowRegistration { get; set; } = true;
public bool AutoRedirectToProvider { get; set; } = false;
public string TokenSecret { get; set; } = Guid.NewGuid().ToString();
public int TokenLifetime { get; set; } = 30;
public bool PasswordRequireNonAlphanumeric { get; set; } = false;

View file

@ -0,0 +1,10 @@
using LANCommander.Server.Settings.Models;
namespace LANCommander.Server.Models
{
public class ProviderButtonsModel
{
public IEnumerable<AuthenticationProvider> Providers { get; set; } = new List<AuthenticationProvider>();
public string ReturnUrl { get; set; }
}
}

View file

@ -1,8 +1,9 @@
@using LANCommander.Server.Data
@using LANCommander.Server.Data
@using LANCommander.Server.Settings
@using LANCommander.Server.Settings.Enums
@@using LANCommander.Server.Settings.Enums
using LANCommander.Server.Data
@using Microsoft.Extensions.Options
@inject NavigationManager NavigationManager
@inject IOptions<Settings> Settings
@code {
protected override void OnInitialized()
@ -10,12 +11,21 @@ using LANCommander.Server.Data
if (DatabaseContext.Provider == DatabaseProvider.Unknown)
{
NavigationManager.NavigateTo("/FirstTimeSetup");
return;
}
else
{
var currentUri = new Uri(NavigationManager.Uri);
NavigationManager.NavigateTo($"/Login?ReturnUrl={currentUri.AbsolutePath}", true);
}
var currentUri = new Uri(NavigationManager.Uri);
var returnUrl = currentUri.AbsolutePath;
var authentication = Settings.Value.Server.Authentication;
var providerCount = authentication.AuthenticationProviders?.Count() ?? 0;
// Only route through the external provider flow when auto-redirect is enabled and
// at least one provider is configured. The ExternalLogin page will challenge the
// single provider directly or present the list when multiple are available.
if (authentication.AutoRedirectToProvider && providerCount > 0)
NavigationManager.NavigateTo($"/ExternalLogin?ReturnUrl={returnUrl}", true);
else
NavigationManager.NavigateTo($"/Login?ReturnUrl={returnUrl}", true);
}
}

View file

@ -0,0 +1,38 @@
@page "/ExternalLogin"
@using LANCommander.Server.Extensions
@model LANCommander.Server.UI.Pages.Account.ExternalLoginModel
@{ Layout = "/UI/Views/Shared/_LayoutBasic.cshtml"; }
@{
ViewData["Title"] = "Sign in";
}
<div class="login-container">
<div class="login-container-background" style="background-image: url('@Model.ScreenshotUrl')"></div>
<div class="ant-row login-box">
<div class="ant-col ant-col-xs-24 ant-col-md-8 ant-col-lg-6 ant-col-md-push-4 ant-col-lg-push-6 login-form-pane">
<div class="login-box-logo">
<img src="~/static/logo-dark.svg"/>
</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>
}
<partial name="_ProviderButtons" model="@(new LANCommander.Server.Models.ProviderButtonsModel { Providers = Model.Providers, ReturnUrl = Model.ReturnUrl })"/>
<div class="ant-flex ant-flex-justify-center" style="margin-top: 16px;">
<a href="~/Login?ReturnUrl=@Uri.EscapeDataString(Model.ReturnUrl ?? "/")">Sign in with a password</a>
</div>
</div>
<div class="ant-col ant-col-xs-24 ant-col-md-8 ant-col-lg-6 ant-col-md-push-4 ant-col-lg-push-6 login-download-pane" style="background-image: url('@Model.ScreenshotUrl')">
@(await Html.RenderComponentAsync<LANCommander.Server.UI.Pages.Account.Components.LauncherDownloadButton>(RenderMode.Server))
</div>
</div>
</div>

View file

@ -0,0 +1,121 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using LANCommander.Server.Data;
using LANCommander.Server.Extensions;
using LANCommander.Server.Models;
using LANCommander.Server.Services;
using LANCommander.Server.Settings.Enums;
using LANCommander.Server.Settings.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using User = LANCommander.Server.Data.Models.User;
namespace LANCommander.Server.UI.Pages.Account
{
public class ExternalLoginModel : PageModel
{
private readonly SignInManager<User> SignInManager;
private readonly RoleService RoleService;
private readonly ILogger<ExternalLoginModel> Logger;
private readonly IOptions<Server.Settings.Settings> Settings;
public ExternalLoginModel(
SignInManager<User> signInManager,
RoleService roleService,
ILogger<ExternalLoginModel> logger,
IOptions<Server.Settings.Settings> settings)
{
SignInManager = signInManager;
RoleService = roleService;
Logger = logger;
Settings = settings;
}
public string ReturnUrl { get; set; }
public string ScreenshotUrl { get; set; }
public IEnumerable<AuthenticationProvider> Providers { get; set; } = new List<AuthenticationProvider>();
public async Task<IActionResult> OnGetAsync(string returnUrl = null, string error = null)
{
returnUrl ??= Url.Content("~/");
ReturnUrl = returnUrl;
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
if (DatabaseContext.Provider == DatabaseProvider.Unknown)
return Redirect("/FirstTimeSetup");
var administratorRole = await RoleService
.Include(r => r.UserRoles)
.FirstOrDefaultAsync(r => r.Name == RoleService.AdministratorRoleName);
if (administratorRole == null || administratorRole.UserRoles != null && !administratorRole.UserRoles.Any())
return Redirect("/FirstTimeSetup");
if (!String.IsNullOrEmpty(error))
ModelState.AddModelError(string.Empty, error);
Providers = HttpContext.GetExternalProviders()?.ToList() ?? new List<AuthenticationProvider>();
var providerCount = Providers.Count();
// Fall back to the standard login page if auto-redirect is disabled or there
// are no providers to redirect to.
if (!Settings.Value.Server.Authentication.AutoRedirectToProvider || providerCount == 0)
return Redirect($"/Login?ReturnUrl={Uri.EscapeDataString(returnUrl)}");
// A single provider can be challenged directly without showing the list.
if (providerCount == 1)
return ChallengeProvider(Providers.First().Slug, returnUrl);
LoadScreenshot();
return Page();
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null, string provider = null)
{
returnUrl ??= Url.Content("~/");
if (returnUrl == "/Logout")
returnUrl = "/";
if (!String.IsNullOrWhiteSpace(provider) && await HttpContext.IsProviderSupportedAsync(provider))
return ChallengeProvider(provider, returnUrl);
return Redirect($"/ExternalLogin?ReturnUrl={Uri.EscapeDataString(returnUrl)}");
}
private IActionResult ChallengeProvider(string provider, string returnUrl)
{
var properties = new AuthenticationProperties(new Dictionary<string, string>()
{
{ "Action", AuthenticationProviderActionType.Login.ToString() }
});
properties.RedirectUri = returnUrl;
return Challenge(properties, provider);
}
private void LoadScreenshot()
{
var screenshots = Directory.GetFiles(Path.Combine("wwwroot", "static", "login"), "*.jpg");
if (screenshots.Any())
ScreenshotUrl = screenshots[new Random().Next(0, screenshots.Length - 1)].Replace("wwwroot", "").Replace(Path.DirectorySeparatorChar, '/');
}
}
}

View file

@ -82,32 +82,7 @@
</span>
</span>
<div class="authentication-provider-container">
<div class="authentication-provider-button-group">
@foreach (var provider in providers)
{
<div class="authentication-provider-button">
<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" style="@(!String.IsNullOrWhiteSpace(provider.Color) ? $"background-color: {provider.Color}; border-color: {provider.Color}" : "")">
@if (!String.IsNullOrWhiteSpace(provider.Icon))
{
<span class="anticon" role="img">
<svg focusable="false" width="1em" height="1em" fill="currentColor">
<use xlink:href="_content/LANCommander.UI/bootstrap-icons.svg#@provider.Icon"/>
</svg>
</span>
}
<span>Sign in using @provider.Name</span>
</button>
</form>
</div>
}
</div>
</div>
<partial name="_ProviderButtons" model="@(new LANCommander.Server.Models.ProviderButtonsModel { Providers = providers, ReturnUrl = Model.ReturnUrl })"/>
}
</div>

View file

@ -0,0 +1,28 @@
@model LANCommander.Server.Models.ProviderButtonsModel
<div class="authentication-provider-container">
<div class="authentication-provider-button-group">
@foreach (var provider in Model.Providers)
{
<div class="authentication-provider-button">
<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" style="@(!String.IsNullOrWhiteSpace(provider.Color) ? $"background-color: {provider.Color}; border-color: {provider.Color}" : "")">
@if (!String.IsNullOrWhiteSpace(provider.Icon))
{
<span class="anticon" role="img">
<svg focusable="false" width="1em" height="1em" fill="currentColor">
<use xlink:href="_content/LANCommander.UI/bootstrap-icons.svg#@provider.Icon"/>
</svg>
</span>
}
<span>Sign in using @provider.Name</span>
</button>
</form>
</div>
}
</div>
</div>

View file

@ -27,6 +27,10 @@
<Switch @bind-Checked="context.Server.Authentication.RequireApproval" />
</FormItem>
<FormItem Label="Auto-redirect to external provider">
<Switch @bind-Checked="context.Server.Authentication.AutoRedirectToProvider" />
</FormItem>
<FormItem Label="Token Secret">
<InputPassword @bind-Value="context.Server.Authentication.TokenSecret" />
</FormItem>