Adding security

This commit is contained in:
Zeeshaun
2026-03-24 09:49:29 -05:00
parent 69648a3b8f
commit a3b74e62a9
59 changed files with 2480 additions and 0 deletions
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LCU.Library.Internal.Security.Models.AccessPolicy;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
namespace LCU.Library.Internal.Security;
public static class AccessPolicyFactory
{
public static IEnumerable<(string Name, Action<AuthorizationPolicyBuilder> factory)> HandlePoliciesFromConfiguration(AccessPolicy policyConfiguration, string? parentName = null)
{
(string Name, Action<AuthorizationPolicyBuilder>) policy = (
$"{(parentName is not null ? $"{parentName}." : string.Empty)}{policyConfiguration.Name}",
b => b
.RequireAuthenticatedUser()
.AddRequirements(new GroupMembershipRequirement(policyConfiguration.Groups))
);
var childPolicies = policyConfiguration.Policies?.SelectMany(p => HandlePoliciesFromConfiguration(p, policy.Name)) ??
Enumerable.Empty<(string Name, Action<AuthorizationPolicyBuilder> factory)>();
return new[] { policy }.Concat(childPolicies);
}
public static AccessPolicies BuildPoliciesFromConfiguration(IConfigurationSection configuration)
{
var policies = configuration
.GetSection("AccessPolicies")
.GetChildren()
.Select(PolicyFactory);
return new AccessPolicies(policies.ToArray());
}
private static AccessPolicy PolicyFactory(IConfigurationSection configuration)
{
return new AccessPolicy
(
configuration.Key,
configuration.GetValue<string>(nameof(AccessPolicy.Groups))?.Split(",").Select(g => g.Trim()).ToArray(),
configuration.GetChildren().Where(c => c[nameof(AccessPolicy.Groups)] is not null).Select(PolicyFactory).ToArray()
);
}
}
@@ -0,0 +1,13 @@
using LCU.Library.Internal.Security.Dao.Client;
using LCU.Library.Internal.Security.Service;
using Microsoft.Extensions.DependencyInjection;
namespace LCU.Library.Internal.Security;
public static class AddSecurityServiceCollectionExtension
{
public static IServiceCollection AddSecurityServices(this IServiceCollection services) => services
.AddScoped<IDaoClient, DaoClient>()
.AddScoped<IAzureAdVerificationService, AzureAdVerificationService>()
.AddScoped<IUsernamePasswordAuthorizationService, UsernamePasswordAuthorizationService>();
}
@@ -0,0 +1,39 @@
using System.Linq;
using LCU.Library.Internal.Security.Service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Identity.Web;
namespace LCU.Library.Internal.Security;
public static class AzureAdGroupPolicyServiceCollectionExtension
{
private const string DefaultScheme = "OpenIdConnect";
public static IServiceCollection AddAzureAdGroupPolicyServices(this IServiceCollection services,
IConfigurationSection azureAdConfiguration,
IConfigurationSection securityConfiguration,
string defaultScheme = DefaultScheme)
{
services.AddAuthentication(defaultScheme)
.AddMicrosoftIdentityWebApp(azureAdConfiguration);
// Azure AD groups policies
var security = AccessPolicyFactory.BuildPoliciesFromConfiguration(securityConfiguration);
services.AddAuthorization(options =>
{
options.FallbackPolicy = options.DefaultPolicy;
foreach (var (name, policy) in security.Policies.SelectMany(p => AccessPolicyFactory.HandlePoliciesFromConfiguration(p)))
{
options.AddPolicy(name, policy);
}
});
services.AddScoped<IAzureAdAuthorizationService, AzureAdAuthorizationService>();
services.AddSingleton<IAuthorizationHandler, GroupMembershipRequirementHandler>();
return services;
}
}
@@ -0,0 +1,49 @@
using System;
using LCU.Library.Internal.Security.Models.Configuration;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace LCU.Library.Internal.Security;
public static class AzureAdServiceCollectionExtensions
{
public static AuthenticationBuilder AddAzureAdBearer(this AuthenticationBuilder builder)
=> builder.AddAzureAdBearer(_ => { });
public static AuthenticationBuilder AddAzureAdBearer(this AuthenticationBuilder builder, Action<AzureActiveDirectoryOptions> configureOptions)
{
builder.Services.Configure(configureOptions);
builder.Services.AddSingleton<IConfigureOptions<JwtBearerOptions>, ConfigureAzureOptions>();
builder.AddJwtBearer();
return builder;
}
private class ConfigureAzureOptions : IConfigureNamedOptions<JwtBearerOptions>
{
private readonly AzureActiveDirectoryOptions _azureOptions;
public ConfigureAzureOptions(IOptions<AzureActiveDirectoryOptions> azureOptions)
{
_azureOptions = azureOptions.Value;
}
public void Configure(string name, JwtBearerOptions options)
{
options.Audience = _azureOptions.ClientId;
options.Authority = $"{_azureOptions.Instance}{_azureOptions.TenantId}";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = _azureOptions.ValidAudience
};
}
public void Configure(JwtBearerOptions options)
{
Configure(Options.DefaultName, options);
}
}
}
@@ -0,0 +1,25 @@
#nullable enable
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using LCU.Library.Internal.Exceptions;
using LCU.Library.Internal.Security.HttpsClient;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace LCU.Library.Internal.Security.Dao.Client;
public class DaoClient : IDaoClient
{
public async Task<T2?> Post<T2>(string url, string uri, string body)
{
var client = TrustingHttpClient.GetClient(url);
var stringContent = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded");
var response = await client.PostAsync(uri, stringContent);
if (!response.IsSuccessStatusCode)
{
throw new ServiceException($"StatusCode:{response.StatusCode}, Content: {response.Content.ReadAsStringAsync().Result}");
}
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T2>(json);
}
}
@@ -0,0 +1,9 @@
#nullable enable
using System.Threading.Tasks;
namespace LCU.Library.Internal.Security.Dao.Client;
public interface IDaoClient
{
public Task<TResponse?> Post<TResponse>(string url, string uri, string body);
}
@@ -0,0 +1,23 @@
using System.Linq;
using System.Threading.Tasks;
using LCU.Library.Internal.Security.Models.AccessPolicy;
using Microsoft.AspNetCore.Authorization;
namespace LCU.Library.Internal.Security;
public class GroupMembershipRequirementHandler : AuthorizationHandler<GroupMembershipRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, GroupMembershipRequirement requirement)
{
var groups = context.User.Claims.Where(c => c.Type == "groups")
.Concat(context.User.Claims.Where(c => c.Type == "roles"))
.Select(c => c.Value);
if (groups.Any(requirement.Groups.Contains))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,25 @@
using System;
using System.Net.Http;
using System.Security.Authentication;
namespace LCU.Library.Internal.Security.HttpsClient;
public static class TrustingHttpClient
{
public static HttpClient GetClient(string endPoint)
{
var handler = new HttpClientHandler
{
SslProtocols = SslProtocols.Tls12 |
SslProtocols.Tls11 |
SslProtocols.Tls,
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(60),
BaseAddress = new Uri(endPoint)
};
return httpClient;
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Title>LCU.Library.Internal.Security</Title>
<Authors>Landmark Credit Union</Authors>
<RepositoryUrl>https://landmarkcu.visualstudio.com/Platform/_git/LCU.Library.Internal.Security</RepositoryUrl>
<RepositoryType>Git</RepositoryType>
<Company>Landmark Credit Union</Company>
<AppDesignerFolder>Properties</AppDesignerFolder>
<AssemblyVersion>2.0.0</AssemblyVersion>
<FileVersion>2.0.0</FileVersion>
<TargetFramework>net8.0</TargetFramework>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageVersion>2.0.0</PackageVersion>
<LangVersion>latest</LangVersion>
<Description>
Security Lib for LCU API authentication using Azure AD
2.0.0 - Updated to .NET 8
</Description>
<PackageReleaseNotes>
Security Lib for LCU API authentication using Azure AD
2.0.0 - Updated to .NET 8
</PackageReleaseNotes>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LCU.Library.Internal.Exceptions" Version="1.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.13" />
<PackageReference Include="Microsoft.Identity.Web" Version="3.7.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LCU.Library.Internal.Security.Models\LCU.Library.Internal.Security.Models.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LCU.Library.Internal.SecurityTests")]
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Serilog;
namespace LCU.Library.Internal.Security.Service;
public class AzureAdAuthorizationService : IAzureAdAuthorizationService
{
private readonly AuthenticationStateProvider _authenticationStateProvider;
private readonly IAuthorizationService _authorizationService;
public AzureAdAuthorizationService
(
AuthenticationStateProvider authenticationStateProvider,
IAuthorizationService authorizationService
)
{
_authenticationStateProvider = authenticationStateProvider;
_authorizationService = authorizationService;
}
public string GetUserName()
{
try
{
return _authenticationStateProvider.GetAuthenticationStateAsync()
.Result.User.Claims.FirstOrDefault(x => x.Type == "name")?.Value ?? string.Empty;
}
catch (Exception ex)
{
Log.Error(ex, "Failure to authenticate current user.");
throw new Exception("Failure to authenticate current user.");
}
}
public bool IsInGroup(string group)
{
return GetAuthorizedGroups().Contains(group);
}
public List<string> GetAuthorizedGroups()
{
try
{
var groups = _authenticationStateProvider.GetAuthenticationStateAsync().Result.User.Claims
.Where(x => x.Type.Contains("groups"))
.Select(x => x.Value).ToList();
if (!groups.Any())
{
throw new Exception("No groups were found for current user.");
}
return groups;
}
catch (Exception ex)
{
Log.Error(ex, "Failure to get authenticated groups for the current user.");
throw new Exception("Failure to get authenticated groups for the current user.");
}
}
public bool PolicySatisfied(string policy)
=> _authorizationService
.AuthorizeAsync(_authenticationStateProvider.GetAuthenticationStateAsync().Result.User, policy)
.Result.Succeeded;
}
@@ -0,0 +1,109 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using System.Web;
using LCU.Library.Internal.Exceptions;
using LCU.Library.Internal.Security.Dao.Client;
using LCU.Library.Internal.Security.Models.Configuration;
using LCU.Library.Internal.Security.Models.Response;
namespace LCU.Library.Internal.Security.Service;
public class AzureAdVerificationService : IAzureAdVerificationService
{
private const string ClientId = "client_id";
private const string Scope = "scope";
private const string Username = "username";
private const string Password = "password";
private const string GrantType = "grant_type";
private const string State = "state";
private const string ClientSecret = "client_secret";
private const string GrantTypePassword = "password";
private readonly AzureAdJwtRetrievalConfiguration _configuration;
private readonly IDaoClient _daoClient;
public AzureAdVerificationService(IDaoClient daoClient, AzureAdJwtRetrievalConfiguration configuration)
{
_daoClient = daoClient;
_configuration = configuration;
}
public async Task<JwtSecurityToken?> GetTokenForWebApiUsingUsernamePasswordAsync(string userName, string password)
{
try{
var handler = new JwtSecurityTokenHandler();
var stateIdentifier = Guid.NewGuid().ToString();
var queryString = HttpUtility.ParseQueryString(string.Empty, Encoding.UTF8);
queryString.Add(ClientId, _configuration.ClientId);
queryString.Add(Scope, _configuration.Scope);
queryString.Add(Username, userName);
queryString.Add(Password, password);
queryString.Add(GrantType, GrantTypePassword);
queryString.Add(State, stateIdentifier);
queryString.Add(ClientSecret, _configuration.ClientSecret);
var result = await _daoClient.Post<AzureAdAuthorizationResponse>(_configuration.AuthenticationUrl, "", HttpUtility.UrlDecode(queryString.ToString()));
if (result == null || handler.ReadToken(result.AccessToken) is not JwtSecurityToken jsonToken){
throw new ServiceException($"Authentication for Username: {userName} could not be performed, a null JWT was returned, or the JWT could not be parsed.");
}
return jsonToken;
} catch (Exception exception){
throw new ServiceException($"Failure to get authentication for username: {userName}. Exception message: {exception.Message}");
}
}
public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken)
{
try
{
IEnumerable<string> RolesFromClaim(string claimName)
{
if (jsonToken.Payload.GetValueOrDefault(claimName)?.ToString() is { Length: > 0 } claims)
{
return JsonSerializer.Deserialize<IEnumerable<string>>(claims)!;
}
return Enumerable.Empty<string>();
}
var roles = RolesFromClaim("roles");
var groups = RolesFromClaim("groups");
return roles.Concat(groups).Any(x => _configuration.Role.Split("|").Any(y => y == x));
} catch (Exception exception){
throw new ServiceException($"Failure to validate roles for JwtSecurityToken. Exception message: {exception.Message}");
}
}
public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken, List<string> approvedRoles)
{
try
{
IEnumerable<string> RolesFromClaim(string claimName)
{
if (jsonToken.Payload.GetValueOrDefault(claimName)?.ToString() is { Length: > 0 } claims)
{
return JsonSerializer.Deserialize<IEnumerable<string>>(claims)!;
}
return Enumerable.Empty<string>();
}
var roles = RolesFromClaim("roles");
var groups = RolesFromClaim("groups");
return roles.Concat(groups).Any(x => approvedRoles.Any(y => y == x));
} catch (Exception exception){
throw new ServiceException($"Failure to validate roles for JwtSecurityToken. Exception message: {exception.Message}");
}
}
}
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Serilog;
namespace LCU.Library.Internal.Security.Service;
public interface IAzureAdAuthorizationService
{
public string GetUserName();
public bool IsInGroup(string group);
public List<string> GetAuthorizedGroups();
public bool PolicySatisfied(string policy);
}
@@ -0,0 +1,13 @@
#nullable enable
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Threading.Tasks;
namespace LCU.Library.Internal.Security.Service;
public interface IAzureAdVerificationService
{
Task<JwtSecurityToken?> GetTokenForWebApiUsingUsernamePasswordAsync(string userName, string password);
public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken);
public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken, List<string> approvedRoles);
}
@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using LCU.Library.Internal.Security.Models.AuthRequest;
using Microsoft.AspNetCore.Mvc;
namespace LCU.Library.Internal.Security.Service;
public interface IUsernamePasswordAuthorizationService
{
Task<IActionResult> OnAuthorizationAsync(AuthRequest authRequest);
}
@@ -0,0 +1,34 @@
using System.Threading.Tasks;
using LCU.Library.Internal.Security.Models.AuthRequest;
using Microsoft.AspNetCore.Mvc;
namespace LCU.Library.Internal.Security.Service;
internal class UsernamePasswordAuthorizationService : IUsernamePasswordAuthorizationService
{
private readonly IAzureAdVerificationService _azureAdVerificationService;
public UsernamePasswordAuthorizationService(IAzureAdVerificationService azureAdVerificationService)
{
_azureAdVerificationService = azureAdVerificationService;
}
public async Task<IActionResult> OnAuthorizationAsync(AuthRequest authRequest)
{
var jwtToken = await _azureAdVerificationService.GetTokenForWebApiUsingUsernamePasswordAsync(authRequest.UserName, authRequest.Password);
if (jwtToken is null)
{
return new UnauthorizedResult();
}
var authorized = _azureAdVerificationService.ValidateJwtHasAuthorizedRole(jwtToken);
if (!authorized)
{
return new ForbidResult();
}
return null;
}
}