2.2.1: The authentication handler has been updated to skip the token validation if the method from controller is marked with [AllowAnonymous] attribute.

Added authenticated user groups and roles in claims.
master
Tudor Stanciu 2023-04-12 19:31:03 +03:00
parent a1841c5727
commit ce04d2c142
10 changed files with 262 additions and 75 deletions

View File

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace Netmash.Security.Authentication.Tuitio.Abstractions
{
public interface IUserContextAccessor
{
bool UserIsLoggedIn { get; }
string UserId { get; }
string UserName { get; }
IEnumerable<(int id, string code)> UserGroups { get; }
IEnumerable<(int id, string code)> UserRoles { get; }
bool IsAnonymousGuest { get; }
}
}

View File

@ -1,8 +1,9 @@
using Tuitio.Wrapper;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using Netmash.Security.Authentication.Tuitio.Abstractions;
using Netmash.Security.Authentication.Tuitio.Services;
using System;
using Tuitio.Wrapper;
namespace Netmash.Security.Authentication.Tuitio
{
@ -11,6 +12,8 @@ namespace Netmash.Security.Authentication.Tuitio
public static IServiceCollection AddTuitioAuthentication(this IServiceCollection services, string tuitioBaseAddress)
{
services.AddTuitioAuthentication(tuitioBaseAddress, new Models.AuthenticationOptions());
services.AddHttpContextAccessor();
services.AddScoped<IUserContextAccessor, UserContextAccessor>();
return services;
}

View File

@ -1,11 +1,27 @@
namespace Netmash.Security.Authentication.Tuitio.Constants
{
/// <summary>
/// All claims except "IsAnonymousGuest" are calculated based on the roles the user has.
/// IsAnonymousGuest is calculated by the client application through the "AuthenticateAsGuest" option it sent to the authentication handler.
/// More precisely, the handler checks the existence and validity of the token, and in case of a negative result, if there is a declared "AuthenticateAsGuest" function, it means that the client application also wants to be contacted before the authentication fails.
/// This function receives as a parameter the entire http request and will have full power to decide whether to authorize it or not.
/// If the client application decides to authorize the request based on its internal logic, a ticket will be created with three claims (GuestId, GuestName and IsAnonymousGuest: true).
/// The id and name of the guest are also given in the configuration by the client application, similar to the "AuthenticateAsGuest" function. Their role is strictly to be used to build the ticket and to be made available to the client application afterwards.
/// </summary>
public struct ClaimTypes
{
public const string
UserName = "UserName",
FirstName = "FirstName",
LastName = "LastName",
IsGuestUser = "IsGuestUser";
UserGroups = "UserGroups",
UserRoles = "UserRoles",
IsAnonymousGuest = "IsAnonymousGuest";
}
public struct ClaimIssuer
{
public const string
Tuitio = "Tuitio";
}
}

View File

@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Linq;
using Tuitio.PublishedLanguage.Dto;
namespace Netmash.Security.Authentication.Tuitio.Extensions
{
internal static class DataConversions
{
public static IEnumerable<(int id, string code)> ToTuples(this IEnumerable<RecordIdentifier> recordIdentifiers)
{
var tuples = recordIdentifiers.Select(z => (z.Id, z.Code));
return tuples;
}
}
}

View File

@ -0,0 +1,44 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Netmash.Security.Authentication.Tuitio.Models
{
internal class ClaimCollection : Dictionary<string, object>
{
public void TryAddClaim(object value, params string[] keys)
{
if (keys.Any(z => string.IsNullOrEmpty(z)))
throw new Exception($"All keys must have value.");
if (value == null)
return;
var isPrimitive = value.GetType().IsPrimitive;
if (!isPrimitive)
{
value = JsonConvert.SerializeObject(value);
}
foreach (string key in keys)
Add(key, value);
}
public void TryAddRange(Dictionary<string, string> claims, Action<string, string> notifyKeyExistence = null)
{
if (claims == null || !claims.Any())
return;
foreach (var claim in claims)
{
if (ContainsKey(claim.Key))
{
notifyKeyExistence?.Invoke(claim.Key, claim.Value);
continue;
}
Add(claim.Key, claim.Value);
}
}
}
}

View File

@ -13,11 +13,13 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
<Company>Toodle HomeLab</Company>
<Copyright>Toodle Netmash</Copyright>
<Version>2.2.0</Version>
<Version>2.2.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Tuitio.Wrapper" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Metadata" Version="6.0.15" />
<PackageReference Include="Microsoft.AspNetCore.Routing.Abstractions" Version="2.2.0" />
<PackageReference Include="Tuitio.Wrapper" Version="2.2.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
</ItemGroup>

View File

@ -1,4 +1,9 @@
2.2.0 release [2023-04-01 22:24]
2.2.1 release [2023-04-12 21:54]
◾ The authentication handler has been updated to skip the token validation if the method from controller is marked with [AllowAnonymous] attribute.
◾ Tuitio nuget packages upgrade
◾ Added authenticated user groups and roles in claims.
2.2.0 release [2023-04-01 22:24]
◾ Tuitio nuget packages upgrade
◾ Removed user profile picture url from authentication claims

View File

@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Netmash.Security.Authentication.Tuitio.Models;
using System;
using System.Linq;
using System.Security.Claims;
using Tuitio.PublishedLanguage.Dto;
using c = Netmash.Security.Authentication.Tuitio.Constants;
namespace Netmash.Security.Authentication.Tuitio.Services
{
internal class AuthenticationTicketService
{
private readonly string _schemeName;
private readonly ILogger _logger;
public AuthenticationTicketService(string schemeName, ILogger logger)
{
_schemeName = schemeName ?? throw new ArgumentNullException(nameof(schemeName));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public AuthenticationTicket GetGuestAuthenticationTicket(int guestId, string guestName)
{
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, guestId.ToString()),
new Claim(ClaimTypes.Name, guestName),
new Claim(c.ClaimTypes.IsAnonymousGuest, bool.TrueString)
};
var ticket = GetAuthenticationTicket(claims);
return ticket;
}
public AuthenticationTicket GetAuthenticationTicket(AuthorizationResult authorization)
{
if (authorization == null)
throw new ArgumentNullException(nameof(authorization));
var claimCollection = new ClaimCollection()
{
{ ClaimTypes.NameIdentifier, authorization.UserId },
{ ClaimTypes.Name, authorization.UserName },
{ c.ClaimTypes.UserName, authorization.UserName }
};
claimCollection.TryAddClaim(authorization.FirstName, ClaimTypes.GivenName, c.ClaimTypes.FirstName);
claimCollection.TryAddClaim(authorization.LastName, ClaimTypes.Surname, c.ClaimTypes.LastName);
claimCollection.TryAddClaim(authorization.Email, ClaimTypes.Email);
claimCollection.TryAddClaim(authorization.UserGroups, c.ClaimTypes.UserGroups);
claimCollection.TryAddClaim(authorization.UserRoles, c.ClaimTypes.UserRoles);
claimCollection.TryAddRange(authorization.Claims, (key, value) =>
{
_logger.LogWarning($"There is already a claim with key {key} in the collection. The combination {key}:{value} will be ignored.");
});
var claims = claimCollection.Select(z => new Claim(z.Key, z.Value.ToString(), z.Value.GetType().Name, c.ClaimIssuer.Tuitio)).ToArray();
var ticket = GetAuthenticationTicket(claims);
return ticket;
}
private AuthenticationTicket GetAuthenticationTicket(Claim[] claims)
{
var identity = new ClaimsIdentity(claims, _schemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, _schemeName);
return ticket;
}
}
}

View File

@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Http;
using Netmash.Security.Authentication.Tuitio.Abstractions;
using Netmash.Security.Authentication.Tuitio.Extensions;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Tuitio.PublishedLanguage.Dto;
using c = Netmash.Security.Authentication.Tuitio.Constants;
namespace Netmash.Security.Authentication.Tuitio.Services
{
internal class UserContextAccessor : IUserContextAccessor
{
private readonly IHttpContextAccessor _httpAccessor;
public UserContextAccessor(IHttpContextAccessor httpAccessor)
{
_httpAccessor = httpAccessor;
}
public bool UserIsLoggedIn => _httpAccessor.HttpContext.User != null;
public string UserId => GetUserClaim<string>(ClaimTypes.NameIdentifier, true, true, "User id");
public string UserName => GetUserClaim<string>(ClaimTypes.Name, true, true, "User name");
public IEnumerable<(int id, string code)> UserGroups
{
get
{
var groups = GetUserClaim<IEnumerable<RecordIdentifier>>(c.ClaimTypes.UserGroups, false, false);
return groups.ToTuples();
}
}
public IEnumerable<(int id, string code)> UserRoles
{
get
{
var roles = GetUserClaim<IEnumerable<RecordIdentifier>>(c.ClaimTypes.UserRoles, false, false);
return roles.ToTuples();
}
}
public bool IsAnonymousGuest => GetUserClaim<bool>(c.ClaimTypes.IsAnonymousGuest);
private T GetUserClaim<T>(string claimType, bool isMandatory = false, bool isPrimitiveType = true, string claimLabel = null)
{
var claimValue = _httpAccessor.HttpContext.User?.Claims.FirstOrDefault(z => z.Type == claimType)?.Value;
if (string.IsNullOrEmpty(claimValue))
{
if (isMandatory)
throw new Exception($"{claimLabel ?? claimType} could not be retrieved from claims.");
else
return default;
}
try
{
if (isPrimitiveType)
return (T)Convert.ChangeType(claimValue, typeof(T));
else
return JsonConvert.DeserializeObject<T>(claimValue);
}
catch (Exception ex)
{
throw new Exception($"Failed to convert claim {claimLabel ?? claimType} to type {typeof(T).Name}.", ex);
}
}
}
}

View File

@ -1,12 +1,12 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Netmash.Security.Authentication.Tuitio.Abstractions;
using Netmash.Security.Authentication.Tuitio.Services;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Tuitio.PublishedLanguage.Dto;
@ -29,8 +29,18 @@ namespace Netmash.Security.Authentication.Tuitio
_logger = logger;
}
private AuthenticationTicketService TicketService => new(Scheme.Name, _logger);
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// if the method is marked with [AllowAnonymous], the handler will skip token validation.
var endpointFeature = Context.Features.Get<IEndpointFeature>();
var endpoint = endpointFeature?.Endpoint;
if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null)
{
return AuthenticateResult.NoResult();
}
var token = GetAuthorizationToken();
if (token != null)
{
@ -48,14 +58,14 @@ namespace Netmash.Security.Authentication.Tuitio
if (!string.IsNullOrEmpty(authorizationEnvelope.Error))
return AuthenticateResult.Fail(authorizationEnvelope.Error);
var ticket = GetAuthenticationTicket(authorizationEnvelope.Result);
var ticket = TicketService.GetAuthenticationTicket(authorizationEnvelope.Result);
return AuthenticateResult.Success(ticket);
}
var authenticateAsGuest = _authenticationOptions.AuthenticateAsGuest?.Invoke(Request) ?? false;
if (authenticateAsGuest)
{
var guestTicket = GetGuestAuthenticationTicket(_authenticationOptions.GuestUserId, _authenticationOptions.GuestUserName);
var guestTicket = TicketService.GetGuestAuthenticationTicket(_authenticationOptions.GuestUserId, _authenticationOptions.GuestUserName);
return AuthenticateResult.Success(guestTicket);
}
@ -81,69 +91,5 @@ namespace Netmash.Security.Authentication.Tuitio
return null;
}
private AuthenticationTicket GetGuestAuthenticationTicket(int guestId, string guestName)
{
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, guestId.ToString()),
new Claim(ClaimTypes.Name, guestName),
new Claim(Constants.ClaimTypes.IsGuestUser, bool.TrueString)
};
var ticket = GetAuthenticationTicket(claims);
return ticket;
}
private AuthenticationTicket GetAuthenticationTicket(AuthorizationResult authorization)
{
var claimCollection = new Dictionary<string, string>()
{
{ ClaimTypes.NameIdentifier, authorization.UserId.ToString() },
{ ClaimTypes.Name, authorization.UserName },
{ Constants.ClaimTypes.UserName, authorization.UserName }
};
if (authorization.FirstName != null)
{
claimCollection.Add(ClaimTypes.GivenName, authorization.FirstName);
claimCollection.Add(Constants.ClaimTypes.FirstName, authorization.FirstName);
}
if (authorization.LastName != null)
{
claimCollection.Add(ClaimTypes.Surname, authorization.FirstName);
claimCollection.Add(Constants.ClaimTypes.LastName, authorization.LastName);
}
if (authorization.Email != null)
claimCollection.Add(ClaimTypes.Email, authorization.Email);
if (authorization.Claims != null && authorization.Claims.Any())
{
foreach (var claim in authorization.Claims)
{
if (claimCollection.ContainsKey(claim.Key))
{
_logger.LogWarning($"There is already a claim with key {claim.Key} in the collection. The combination {claim.Key}:{claim.Value} will be ignored.");
continue;
}
claimCollection.Add(claim.Key, claim.Value);
}
}
var claims = claimCollection.Select(z => new Claim(z.Key, z.Value)).ToArray();
var ticket = GetAuthenticationTicket(claims);
return ticket;
}
private AuthenticationTicket GetAuthenticationTicket(Claim[] claims)
{
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return ticket;
}
}
}