From ce04d2c142fb6c1dcdd831a014c3dbb7d666a0ea Mon Sep 17 00:00:00 2001 From: Tudor Stanciu Date: Wed, 12 Apr 2023 19:31:03 +0300 Subject: [PATCH] 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. --- .../Abstractions/IUserContextAccessor.cs | 14 ++++ .../AuthenticationExtensions.cs | 7 +- .../Constants/ClaimTypes.cs | 18 +++- .../Extensions/DataConversions.cs | 15 ++++ .../Models/ClaimCollection.cs | 44 ++++++++++ ...mash.Security.Authentication.Tuitio.csproj | 6 +- .../ReleaseNotes.txt | 7 +- .../Services/AuthenticationTicketService.cs | 72 ++++++++++++++++ .../Services/UserContextAccessor.cs | 70 ++++++++++++++++ .../TuitioAuthenticationHandler.cs | 84 ++++--------------- 10 files changed, 262 insertions(+), 75 deletions(-) create mode 100644 src/security/authentication/Netmash.Security.Authentication.Tuitio/Abstractions/IUserContextAccessor.cs create mode 100644 src/security/authentication/Netmash.Security.Authentication.Tuitio/Extensions/DataConversions.cs create mode 100644 src/security/authentication/Netmash.Security.Authentication.Tuitio/Models/ClaimCollection.cs create mode 100644 src/security/authentication/Netmash.Security.Authentication.Tuitio/Services/AuthenticationTicketService.cs create mode 100644 src/security/authentication/Netmash.Security.Authentication.Tuitio/Services/UserContextAccessor.cs diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/Abstractions/IUserContextAccessor.cs b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Abstractions/IUserContextAccessor.cs new file mode 100644 index 0000000..8e4e5a4 --- /dev/null +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Abstractions/IUserContextAccessor.cs @@ -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; } + } +} diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/AuthenticationExtensions.cs b/src/security/authentication/Netmash.Security.Authentication.Tuitio/AuthenticationExtensions.cs index efeb85e..9caaf92 100644 --- a/src/security/authentication/Netmash.Security.Authentication.Tuitio/AuthenticationExtensions.cs +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/AuthenticationExtensions.cs @@ -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(); return services; } diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/Constants/ClaimTypes.cs b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Constants/ClaimTypes.cs index 4a9dc01..2f068f9 100644 --- a/src/security/authentication/Netmash.Security.Authentication.Tuitio/Constants/ClaimTypes.cs +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Constants/ClaimTypes.cs @@ -1,11 +1,27 @@ namespace Netmash.Security.Authentication.Tuitio.Constants { + /// + /// 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. + /// 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"; } } diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/Extensions/DataConversions.cs b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Extensions/DataConversions.cs new file mode 100644 index 0000000..415a42f --- /dev/null +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Extensions/DataConversions.cs @@ -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 recordIdentifiers) + { + var tuples = recordIdentifiers.Select(z => (z.Id, z.Code)); + return tuples; + } + } +} diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/Models/ClaimCollection.cs b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Models/ClaimCollection.cs new file mode 100644 index 0000000..457339c --- /dev/null +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Models/ClaimCollection.cs @@ -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 + { + 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 claims, Action 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); + } + } + } +} diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/Netmash.Security.Authentication.Tuitio.csproj b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Netmash.Security.Authentication.Tuitio.csproj index 4c18dd7..9bf85ac 100644 --- a/src/security/authentication/Netmash.Security.Authentication.Tuitio/Netmash.Security.Authentication.Tuitio.csproj +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Netmash.Security.Authentication.Tuitio.csproj @@ -13,11 +13,13 @@ README.md Toodle HomeLab Toodle Netmash - 2.2.0 + 2.2.1 - + + + diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/ReleaseNotes.txt b/src/security/authentication/Netmash.Security.Authentication.Tuitio/ReleaseNotes.txt index 94891c4..954847f 100644 --- a/src/security/authentication/Netmash.Security.Authentication.Tuitio/ReleaseNotes.txt +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/ReleaseNotes.txt @@ -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 diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/Services/AuthenticationTicketService.cs b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Services/AuthenticationTicketService.cs new file mode 100644 index 0000000..9cdb219 --- /dev/null +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Services/AuthenticationTicketService.cs @@ -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; + } + } +} diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/Services/UserContextAccessor.cs b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Services/UserContextAccessor.cs new file mode 100644 index 0000000..f72211b --- /dev/null +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/Services/UserContextAccessor.cs @@ -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(ClaimTypes.NameIdentifier, true, true, "User id"); + public string UserName => GetUserClaim(ClaimTypes.Name, true, true, "User name"); + public IEnumerable<(int id, string code)> UserGroups + { + get + { + var groups = GetUserClaim>(c.ClaimTypes.UserGroups, false, false); + return groups.ToTuples(); + } + } + + public IEnumerable<(int id, string code)> UserRoles + { + get + { + var roles = GetUserClaim>(c.ClaimTypes.UserRoles, false, false); + return roles.ToTuples(); + } + } + + public bool IsAnonymousGuest => GetUserClaim(c.ClaimTypes.IsAnonymousGuest); + + private T GetUserClaim(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(claimValue); + } + catch (Exception ex) + { + throw new Exception($"Failed to convert claim {claimLabel ?? claimType} to type {typeof(T).Name}.", ex); + } + } + } +} diff --git a/src/security/authentication/Netmash.Security.Authentication.Tuitio/TuitioAuthenticationHandler.cs b/src/security/authentication/Netmash.Security.Authentication.Tuitio/TuitioAuthenticationHandler.cs index 7ebcd71..198ec45 100644 --- a/src/security/authentication/Netmash.Security.Authentication.Tuitio/TuitioAuthenticationHandler.cs +++ b/src/security/authentication/Netmash.Security.Authentication.Tuitio/TuitioAuthenticationHandler.cs @@ -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 HandleAuthenticateAsync() { + // if the method is marked with [AllowAnonymous], the handler will skip token validation. + var endpointFeature = Context.Features.Get(); + var endpoint = endpointFeature?.Endpoint; + if (endpoint?.Metadata.GetMetadata() != 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() - { - { 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; - } } }