LANCommander/LANCommander.Server.Services/AuthenticationService.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

333 lines
13 KiB
C#

using Microsoft.Extensions.Logging;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AutoMapper;
using LANCommander.SDK;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Models;
using LANCommander.Server.Services.Exceptions;
using Microsoft.IdentityModel.Tokens;
using YamlDotNet.Serialization;
using PascalCaseNamingConvention = YamlDotNet.Serialization.NamingConventions.PascalCaseNamingConvention;
namespace LANCommander.Server.Services
{
public class AuthenticationService(
ILogger<AuthenticationService> logger,
IMapper mapper,
SettingsProvider<Settings.Settings> settingsProvider,
UserService userService,
RoleService roleService,
ScriptService scriptService,
ITokenProvider tokenProvider,
SDK.Services.ScriptClient scriptClient) : BaseService(logger, settingsProvider)
{
public async Task<AuthToken> LoginAsync(string userName, string password)
{
if (string.IsNullOrWhiteSpace(userName) || await userService.CheckPassword(userName, password) == false)
throw new UserAuthenticationException("Invalid username or password");
var token = await LoginAsync(userName);
try
{
var user = await userService.GetAsync<User>(userName);
var scripts = await scriptService.GetAsync<SDK.Models.Script>(s => s.Type == ScriptType.UserLogin);
if (scripts.Any())
{
tokenProvider.SetToken(token);
foreach (var script in scripts)
{
await scriptClient.Authentication_RunUserLoginScript(script, user);
}
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not execute user login script");
}
return token;
}
public async Task<AuthToken> LoginAsync(string userName)
{
var user = await userService.GetAsync(userName);
if (user == null)
throw new UserAuthenticationException("Invalid username or password");
_logger?.LogDebug("Password check for user {UserName} was successful", user.UserName);
if (_settingsProvider.CurrentValue.Server.Authentication.RequireApproval && !user.Approved && !await userService.IsInRoleAsync(user, RoleService.AdministratorRoleName))
throw new UserAuthenticationException("Account must be approved by an administrator");
var userRoles = await userService.GetRolesAsync(user);
var authClaims = new List<Claim>
{
new Claim(ClaimTypes.Name, user.UserName),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
foreach (var userRole in userRoles)
{
authClaims.Add(new Claim(ClaimTypes.Role, userRole.Name));
}
_logger?.LogDebug("Generating authentication token for user {UserName}", user.UserName);
var token = GetToken(authClaims);
var refreshToken = GenerateRefreshToken();
user.RefreshToken = refreshToken;
user.RefreshTokenExpiration = DateTime.UtcNow.AddDays(_settingsProvider.CurrentValue.Server.Authentication.TokenLifetime);
await userService.UpdateAsync(user);
return new AuthToken
{
AccessToken = new JwtSecurityTokenHandler().WriteToken(token),
RefreshToken = refreshToken,
Expiration = token.ValidTo
};
}
public async Task<AuthToken> RefreshTokenAsync(AuthToken token)
{
if (token == null)
{
_logger?.LogDebug("Refresh token is null");
throw new Exception("Invalid refresh token");
}
var principal = GetPrincipalFromExpiredToken(token.AccessToken);
if (principal == null)
{
_logger?.LogDebug("Invalid access token or refresh token");
throw new Exception("Invalid access token or refresh token");
}
var user = await userService.GetAsync(principal.Identity.Name);
if (user == null || user.RefreshToken != token.RefreshToken ||
user.RefreshTokenExpiration <= DateTime.UtcNow)
{
_logger?.LogDebug("Refresh token expired");
throw new Exception("Invalid refresh token");
}
var newAccessToken = GetToken(principal.Claims.ToList());
var newRefreshToken = GenerateRefreshToken();
user.RefreshToken = newRefreshToken;
await userService.UpdateAsync(user);
_logger?.LogDebug("Refresh token updated for user {UserName}", user.UserName);
return new AuthToken
{
AccessToken = new JwtSecurityTokenHandler().WriteToken(newAccessToken),
RefreshToken = newRefreshToken,
Expiration = newAccessToken.ValidTo
};
}
public async Task<AuthToken> RegisterAsync(string userName, string password)
{
if (!settingsProvider.CurrentValue.Server.Authentication.AllowRegistration)
throw new UserRegistrationException("User registration is disabled");
if (string.IsNullOrWhiteSpace(password))
throw new UserRegistrationException("Password is empty");
var user = await userService.GetAsync(userName);
if (user != null)
{
_logger?.LogDebug("Cannot register user with username {UserName}, already exists", userName);
throw new UserRegistrationException("Username is unavailable");
}
user = new Data.Models.User();
user.UserName = userName;
var registerResult = await userService.CheckRegister(user, password);
if (!registerResult.Succeeded)
{
throw new UserRegistrationException(registerResult, "Could not register user");
}
user = await userService.AddAsync(user);
if (user != null)
{
await userService.ChangePassword(user.UserName, password);
try
{
if (_settingsProvider.CurrentValue.Server.Roles.DefaultRoleId == Guid.Empty)
{
var defaultRole = await roleService.GetAsync(_settingsProvider.CurrentValue.Server.Roles.DefaultRoleId);
if (defaultRole != null)
await userService.AddToRoleAsync(user.UserName, defaultRole.Name);
}
var token = await LoginAsync(user.UserName, password);
logger?.LogDebug("Successfully registered user {UserName}", user.UserName);
try
{
var scripts = await scriptService.GetAsync<SDK.Models.Script>(s => s.Type == ScriptType.UserLogin);
if (scripts.Any())
{
tokenProvider.SetToken(token);
foreach (var script in scripts)
{
await scriptClient.Authentication_RunUserRegistrationScript(script, mapper.Map<SDK.Models.User>(user));
}
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not execute user login script");
}
return token;
}
catch (Exception ex)
{
logger?.LogError(ex, "Could not register user {UserName}", user.UserName);
throw new UserRegistrationException("An unknown error occurred while registering");
}
}
else
throw new UserRegistrationException("Unknown Error");
}
private ClaimsPrincipal? GetPrincipalFromExpiredToken(string? token)
{
var tokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settingsProvider.CurrentValue.Server.Authentication.TokenSecret)),
ValidateLifetime = false
};
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken securityToken);
if (securityToken is not JwtSecurityToken jwtSecurityToken || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
throw new SecurityTokenException("Invalid token");
return principal;
}
private static string GenerateRefreshToken()
{
var randomNumber = new byte[64];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}
private JwtSecurityToken GetToken(List<Claim> authClaims)
{
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settingsProvider.CurrentValue.Server.Authentication.TokenSecret));
var token = new JwtSecurityToken(
expires: DateTime.UtcNow.AddDays(_settingsProvider.CurrentValue.Server.Authentication.TokenLifetime),
claims: authClaims,
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
);
return token;
}
public static async Task<List<Settings.Models.AuthenticationProvider>> GetAuthenticationProviderTemplatesAsync()
{
var templateDirectory = AppPaths.GetConfigPath("Templates", "AuthenticationProviders");
if (!Directory.Exists(templateDirectory))
return [];
var files = Directory.GetFiles(templateDirectory, "*.yml", SearchOption.AllDirectories);
var externalProviders = new List<Settings.Models.AuthenticationProvider>();
var deserializer = new DeserializerBuilder()
.WithNamingConvention(PascalCaseNamingConvention.Instance)
.Build();
foreach (var file in files)
{
try
{
var contents = await File.ReadAllTextAsync(file);
externalProviders.Add(deserializer.Deserialize<Settings.Models.AuthenticationProvider>(contents));
}
catch { }
}
return externalProviders;
}
/// <summary>
/// Reads the provider's OpenID Connect discovery document (the well-known
/// configuration URL) and returns the advertised <c>claims_supported</c> and
/// <c>scopes_supported</c> values. These are advisory only: the fields are
/// optional in the spec and many providers under-report them, so they should be
/// treated as UI suggestions rather than an authoritative set.
/// </summary>
public static async Task<(IReadOnlyCollection<string> Claims, IReadOnlyCollection<string> Scopes)> GetDiscoveryMetadataAsync(string configurationUrl)
{
if (String.IsNullOrWhiteSpace(configurationUrl))
return (Array.Empty<string>(), Array.Empty<string>());
using var http = new HttpClient();
using var stream = await http.GetStreamAsync(configurationUrl);
using var document = await JsonDocument.ParseAsync(stream);
var claims = ReadStringArray(document.RootElement, "claims_supported");
var scopes = ReadStringArray(document.RootElement, "scopes_supported");
return (claims, scopes);
}
private static IReadOnlyCollection<string> ReadStringArray(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var array) || array.ValueKind != JsonValueKind.Array)
return Array.Empty<string>();
return array.EnumerateArray()
.Where(c => c.ValueKind == JsonValueKind.String)
.Select(c => c.GetString())
.Where(s => !String.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
}