LANCommander/LANCommander.Server/Startup/Authentication.cs
Pat Hartl f1fa66b632 Improve advanced auth provider editing, add role claim mapping
- Added auto discovery of scopes and claim mappings to auth provider editor
- Added the ability to map claims to roles
- Auto-provision users logged in over external auth

Ref #411
2026-06-21 02:03:51 -05:00

453 lines
No EOL
18 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using LANCommander.Server.Data.Models;
using LANCommander.Server.Models;
using LANCommander.Server.Services;
using LANCommander.Server.Services.Models;
using LANCommander.Server.Settings.Enums;
using LANCommander.Server.Settings.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using ZiggyCreatures.Caching.Fusion;
namespace LANCommander.Server.Startup;
public static class Authentication
{
public static WebApplicationBuilder ConfigureAuthentication(this WebApplicationBuilder builder)
{
builder.Services.AddCascadingAuthenticationState();
return builder;
}
public static AuthenticationBuilder AddAuthenticationProviders(this AuthenticationBuilder authBuilder, Settings.Settings settings)
{
foreach (var authenticationProvider in settings.Server.Authentication.AuthenticationProviders)
{
try
{
switch (authenticationProvider.Type)
{
case AuthenticationProviderType.OAuth2:
authBuilder.AddOAuth(authenticationProvider, settings);
break;
case AuthenticationProviderType.OpenIdConnect:
authBuilder.AddOpenIdConnect(authenticationProvider, settings);
break;
case AuthenticationProviderType.Saml:
throw new NotImplementedException("SAML providers are not supported at this time.");
break;
}
}
catch (Exception ex)
{
Console.WriteLine($"Error: Authentication Provider {authenticationProvider.Name} could not be registered: {ex.Message}");
}
}
return authBuilder;
}
public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder authBuilder, AuthenticationProvider authenticationProvider, Settings.Settings settings)
{
if (String.IsNullOrWhiteSpace(authenticationProvider.Slug))
return authBuilder;
return authBuilder.AddOpenIdConnect(authenticationProvider.Slug, authenticationProvider.Name, options =>
{
options.ClientId = authenticationProvider.ClientId;
options.ClientSecret = authenticationProvider.ClientSecret;
options.MetadataAddress = authenticationProvider.ConfigurationUrl;
options.ResponseType = OpenIdConnectResponseType.Code;
// Fetch the userinfo endpoint so configured claim mappings (which run over
// the userinfo JSON) are applied to the principal.
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false
};
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
if (options.MetadataAddress.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
options.CorrelationCookie.SameSite = settings.Server.Authentication.HttpsCookiePolicy.SameSite;
options.CorrelationCookie.SecurePolicy = settings.Server.Authentication.HttpsCookiePolicy.Secure;
options.NonceCookie.SameSite = settings.Server.Authentication.HttpsCookiePolicy.SameSite;
options.NonceCookie.SecurePolicy = settings.Server.Authentication.HttpsCookiePolicy.Secure;
options.ResponseMode = OpenIdConnectResponseMode.Query;
}
else
{
options.CorrelationCookie.SameSite = settings.Server.Authentication.HttpCookiePolicy.SameSite;
options.CorrelationCookie.SecurePolicy = settings.Server.Authentication.HttpCookiePolicy.Secure;
options.NonceCookie.SameSite = settings.Server.Authentication.HttpCookiePolicy.SameSite;
options.NonceCookie.SecurePolicy = settings.Server.Authentication.HttpCookiePolicy.Secure;
options.ResponseMode = OpenIdConnectResponseMode.Query;
}
// Callbacks for middleware to properly correlate
options.CallbackPath = new PathString($"/SignInOIDC");
options.SignedOutCallbackPath = new PathString($"/SignOutOIDC");
foreach (var scope in authenticationProvider.Scopes)
{
options.Scope.Add(scope);
}
RegisterClaimActions(options.ClaimActions, authenticationProvider);
options.Events.OnRemoteFailure = async context =>
{
Console.WriteLine($"Error: OIDC authentication failed: {context.Failure?.Message}");
context.HandleResponse();
context.Response.Redirect($"/Login?error={Uri.EscapeDataString(context.Failure?.Message ?? "Unknown authentication error")}");
};
options.Events.OnTicketReceived = async context =>
{
context.Properties.RedirectUri ??= context.ReturnUri;
var identity = new ClaimsIdentity(context.Principal.Claims, IdentityConstants.ApplicationScheme);
await ProcessLogin(context.HttpContext, context.Response, identity, authenticationProvider, context.Properties);
context.HandleResponse();
};
});
}
public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder authBuilder,
AuthenticationProvider authenticationProvider, Settings.Settings settings)
{
if (String.IsNullOrWhiteSpace(authenticationProvider.Slug))
return authBuilder;
return authBuilder.AddOAuth(authenticationProvider.Slug, authenticationProvider.Name, options =>
{
options.ClientId = authenticationProvider.ClientId;
options.ClientSecret = authenticationProvider.ClientSecret;
options.CallbackPath = new PathString($"/SignInOAuth");
options.AuthorizationEndpoint = authenticationProvider.AuthorizationEndpoint;
options.TokenEndpoint = authenticationProvider.TokenEndpoint;
options.UserInformationEndpoint = authenticationProvider.UserInfoEndpoint;
foreach (var scope in authenticationProvider.Scopes)
{
options.Scope.Add(scope);
}
options.SaveTokens = true;
RegisterClaimActions(options.ClaimActions, authenticationProvider);
options.Events.OnRemoteFailure = async context =>
{
Console.WriteLine($"Error: OAuth authentication failed: {context.Failure?.Message}");
context.HandleResponse();
context.Response.Redirect($"/Login?error={Uri.EscapeDataString(context.Failure?.Message ?? "Unknown authentication error")}");
};
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 oauthUser = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
context.Identity.AddClaim(new Claim("Provider", authenticationProvider.Name));
context.RunClaimActions(oauthUser.RootElement);
};
options.Events.OnTicketReceived = async context =>
{
context.Properties.RedirectUri ??= context.ReturnUri;
var identity = new ClaimsIdentity(context.Principal.Claims, IdentityConstants.ApplicationScheme);
await ProcessLogin(context.HttpContext, context.Response, identity, authenticationProvider, context.Properties);
context.HandleResponse();
};
});
}
private static async Task ProcessLogin(
HttpContext httpContext,
HttpResponse response,
ClaimsIdentity identity,
AuthenticationProvider authenticationProvider,
AuthenticationProperties properties)
{
var signInManager = httpContext.RequestServices.GetService<SignInManager<User>>()!;
var userService = httpContext.RequestServices.GetService<UserService>()!;
var userCustomFieldService = httpContext.RequestServices.GetService<UserCustomFieldService>()!;
var idClaim = identity.FindFirst(ClaimTypes.NameIdentifier);
User user;
UserCustomField customField;
var principal = new ClaimsPrincipal(identity);
var action = properties.Items["Action"];
switch (action)
{
case AuthenticationProviderActionType.Login:
customField = await userCustomFieldService.FirstOrDefaultAsync(cf => cf.Name == authenticationProvider.GetCustomFieldName() && cf.Value == idClaim.Value);
if (customField != null)
{
user = await userService.GetAsync(customField.UserId.Value);
await SyncRolesAsync(httpContext, principal, user);
await signInManager.SignInAsync(user, true);
response.Redirect(properties.RedirectUri);
}
else
{
user = await ProvisionUserAsync(httpContext, identity, authenticationProvider);
if (user != null)
{
await SyncRolesAsync(httpContext, principal, user);
await signInManager.SignInAsync(user, true);
response.Redirect(properties.RedirectUri);
}
else
{
// No usable username claim, or the username collides with an
// existing local account. Fall back to manual registration.
await httpContext.SignInAsync(IdentityConstants.ApplicationScheme,
principal,
new AuthenticationProperties
{
AllowRefresh = false,
IsPersistent = false,
});
response.Redirect($"/Register?Provider={authenticationProvider.Slug}");
}
}
break;
case AuthenticationProviderActionType.AccountLink:
// Link accounts if needed
var userId = Guid.Parse(properties.Items["UserId"]);
user = await userService.GetAsync(userId);
customField = await userCustomFieldService.FirstOrDefaultAsync(cf => cf.UserId == userId && cf.Name == authenticationProvider.GetCustomFieldName());
var collidingCustomField = await userCustomFieldService.FirstOrDefaultAsync(cf => cf.Name == authenticationProvider.GetCustomFieldName() && cf.Value == idClaim.Value);
if (collidingCustomField != null)
throw new Exception("This account is already linked to an existing user.");
if (customField == null)
{
await userCustomFieldService.AddAsync(new UserCustomField
{
Name = authenticationProvider.GetCustomFieldName(),
UserId = userId,
Value = idClaim.Value
});
}
await signInManager.SignInAsync(user, true);
response.Redirect(properties.RedirectUri);
break;
case AuthenticationProviderActionType.Register:
await httpContext.SignInAsync(IdentityConstants.ApplicationScheme,
principal,
new AuthenticationProperties
{
AllowRefresh = false,
IsPersistent = false,
});
response.Redirect($"/Register?Provider={authenticationProvider.Slug}");
break;
}
}
private static void RegisterClaimActions(ClaimActionCollection claimActions, AuthenticationProvider authenticationProvider)
{
foreach (var claimMapping in authenticationProvider.ClaimMappings)
{
if (String.IsNullOrWhiteSpace(claimMapping.Name) || String.IsNullOrWhiteSpace(claimMapping.Value))
continue;
if (IsRoleMapping(claimMapping.Name))
claimActions.Add(new RolesClaimAction(ProviderClaimTypes.Roles, claimMapping.Value));
else
claimActions.MapJsonKey(claimMapping.Name, claimMapping.Value);
}
}
private static bool IsRoleMapping(string name)
{
return name.Equals(ProviderClaimTypes.Roles, StringComparison.OrdinalIgnoreCase)
|| name.Equals("roles", StringComparison.OrdinalIgnoreCase)
|| name.Equals(ClaimTypes.Role, StringComparison.OrdinalIgnoreCase);
}
private static string FindFirstAny(ClaimsIdentity identity, params string[] claimTypes)
{
foreach (var claimType in claimTypes)
{
var claim = identity.FindFirst(claimType);
if (claim != null && !String.IsNullOrWhiteSpace(claim.Value))
return claim.Value;
}
return null;
}
private static async Task<User> ProvisionUserAsync(
HttpContext httpContext,
ClaimsIdentity identity,
AuthenticationProvider authenticationProvider)
{
var userService = httpContext.RequestServices.GetService<UserService>()!;
var roleService = httpContext.RequestServices.GetService<RoleService>()!;
var userCustomFieldService = httpContext.RequestServices.GetService<UserCustomFieldService>()!;
var settings = httpContext.RequestServices.GetService<SettingsProvider<Settings.Settings>>()!.CurrentValue;
var idClaim = identity.FindFirst(ProviderClaimTypes.NameId);
var username = FindFirstAny(identity, ProviderClaimTypes.Username, "username", "name", "preferred_username");
if (idClaim == null || String.IsNullOrWhiteSpace(username))
return null;
// Never hijack an existing local account that happens to share this username.
var existing = await userService.GetAsync(username);
if (existing != null)
return null;
var user = new User
{
UserName = username,
Email = FindFirstAny(identity, ProviderClaimTypes.Email, "email"),
Alias = FindFirstAny(identity, ProviderClaimTypes.Alias, "alias"),
};
if (!settings.Server.Authentication.RequireApproval)
{
user.Approved = true;
user.ApprovedOn = DateTime.UtcNow;
}
user = await userService.AddAsync(user);
if (settings.Server.Roles.DefaultRoleId != Guid.Empty)
{
var defaultRole = await roleService.GetAsync(settings.Server.Roles.DefaultRoleId);
if (defaultRole != null)
await userService.AddToRoleAsync(user.UserName, defaultRole.Name);
}
await userCustomFieldService.AddAsync(new UserCustomField
{
UserId = user.Id,
Name = authenticationProvider.GetCustomFieldName(),
Value = idClaim.Value,
});
return user;
}
private static async Task SyncRolesAsync(HttpContext httpContext, ClaimsPrincipal principal, User user)
{
var userService = httpContext.RequestServices.GetService<UserService>()!;
var roleService = httpContext.RequestServices.GetService<RoleService>()!;
var settings = httpContext.RequestServices.GetService<SettingsProvider<Settings.Settings>>()!.CurrentValue;
var claimedRoleNames = principal
.FindAll(c => c.Type == ProviderClaimTypes.Roles || c.Type == ClaimTypes.Role)
.Select(c => c.Value)
.Where(v => !String.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
// Roles that must never be removed automatically by a provider login.
var protectedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
RoleService.AdministratorRoleName
};
if (settings.Server.Roles.DefaultRoleId != Guid.Empty)
{
var defaultRole = await roleService.GetAsync(settings.Server.Roles.DefaultRoleId);
if (defaultRole != null)
protectedRoles.Add(defaultRole.Name);
}
// Create any roles named in the claims that don't yet exist.
foreach (var roleName in claimedRoleNames)
{
if (await roleService.GetAsync(roleName) == null)
await roleService.AddAsync(new Role { Name = roleName });
}
var currentRoleNames = (await userService.GetRolesAsync(user)).Select(r => r.Name).ToList();
var rolesToAdd = claimedRoleNames
.Where(rn => !currentRoleNames.Contains(rn, StringComparer.OrdinalIgnoreCase))
.ToList();
var rolesToRemove = currentRoleNames
.Where(rn => !claimedRoleNames.Contains(rn, StringComparer.OrdinalIgnoreCase))
.Where(rn => !protectedRoles.Contains(rn))
.ToList();
if (rolesToAdd.Any())
await userService.AddToRolesAsync(user.UserName, rolesToAdd);
foreach (var roleName in rolesToRemove)
await userService.RemoveFromRole(user.UserName, roleName);
if (rolesToRemove.Any())
{
var cache = httpContext.RequestServices.GetService<IFusionCache>()!;
await cache.RemoveByTagAsync(["User/Security", "User/Roles", $"User/{user.Id}", $"Library/{user.Id}"]);
}
}
}