Adding security
This commit is contained in:
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
@@ -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>();
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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")]
|
||||
+70
@@ -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;
|
||||
}
|
||||
+109
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user