From f7c0f7984e86748dc3480a92e3d5b361fadd8a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poul=20Kjeldager=20S=C3=B8rensen?= Date: Wed, 7 Aug 2024 16:35:29 +0200 Subject: [PATCH] feat: refactored and using latest eav with more universal provider abstraction --- Directory.Build.props | 4 +- ...xtensions.EasyAuth.MicrosoftEntraId.csproj | 4 +- .../MicrosoftEntraEasyAuthProvider.cs | 141 ++++++++++++------ .../MicrosoftEntraIdEasyAuthExtensions.cs | 23 ++- .../MicrosoftEntraIdEasyAuthOptions.cs | 28 +++- 5 files changed, 145 insertions(+), 55 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index fd4b3e2..16a7850 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,8 @@ - 10.0 - 4.2.8 + 12.0 + 4.5.0-dev.2 true $(MSBuildThisFileDirectory)/external/EAVFramework $(MSBuildThisFileDirectory)/external diff --git a/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/EAVFW.Extensions.EasyAuth.MicrosoftEntraId.csproj b/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/EAVFW.Extensions.EasyAuth.MicrosoftEntraId.csproj index 0c87b58..870a3c9 100644 --- a/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/EAVFW.Extensions.EasyAuth.MicrosoftEntraId.csproj +++ b/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/EAVFW.Extensions.EasyAuth.MicrosoftEntraId.csproj @@ -18,7 +18,7 @@ - + @@ -28,5 +28,7 @@ + + diff --git a/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraEasyAuthProvider.cs b/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraEasyAuthProvider.cs index a6145c2..825d76d 100644 --- a/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraEasyAuthProvider.cs +++ b/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraEasyAuthProvider.cs @@ -1,14 +1,22 @@ using EAVFramework; using EAVFramework.Authentication; +using EAVFramework.Authentication.Passwordless; +using EAVFramework.Configuration; using EAVFramework.Endpoints; +using EAVFramework.Extensions; using EAVFW.Extensions.SecurityModel; using IdentityModel.Client; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.JsonWebTokens; using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Linq; @@ -20,99 +28,148 @@ namespace EAVFW.Extensions.EasyAuth.MicrosoftEntraId { - public class MicrosoftEntraEasyAuthProvider : IEasyAuthProvider + public class MicrosoftEntraEasyAuthProvider : DefaultAuthProvider + where TContext : DynamicContext where TSecurityGroup : DynamicEntity, IEntraIDSecurityGroup where TSecurityGroupMember : DynamicEntity, ISecurityGroupMember, new() + where TIdentity : DynamicEntity, IIdentity { private readonly IOptions _options; private readonly IHttpClientFactory _clientFactory; + - public string AuthenticationName => "MicrosoftEntraId"; - - public HttpMethod CallbackHttpMethod => HttpMethod.Post; - - public bool AutoGenerateRoutes { get; set; } = true; - - public MicrosoftEntraEasyAuthProvider() { } + public MicrosoftEntraEasyAuthProvider() :base("MicrosoftEntraId", HttpMethod.Post) { } public MicrosoftEntraEasyAuthProvider( IOptions options, - IHttpClientFactory clientFactory) + IHttpClientFactory clientFactory) : this() { _options = options ?? throw new System.ArgumentNullException(nameof(options)); _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); } - public async Task OnAuthenticate(HttpContext httpcontext, string handleId, string redirectUrl) + public override async Task OnAuthenticate(OnAuthenticateRequest authenticateRequest) { - var email = httpcontext.Request.Query["email"].FirstOrDefault(); - var redirectUri = httpcontext.Request.Query["redirectUri"].FirstOrDefault(); - var callbackUri = $"{httpcontext.Request.Scheme}://{httpcontext.Request.Host}{httpcontext.Request.Path}/callback"; + - var ru = new RequestUrl(_options.Value.GetMicrosoftAuthorizationUrl(httpcontext)); + var callbackurl = new Uri(authenticateRequest.CallbackUrl); + + var ru = new RequestUrl(_options.Value.GetMicrosoftAuthorizationUrl(authenticateRequest.HttpContext)); var authUri = ru.CreateAuthorizeUrl( clientId: _options.Value.ClientId, - redirectUri: callbackUri, + redirectUri: callbackurl.GetLeftPart(UriPartial.Path), responseType: ResponseTypes.Code, responseMode: ResponseModes.FormPost, scope: _options.Value.Scope, - loginHint: String.IsNullOrEmpty(email) || email == "undefined" ? null : email, - state: handleId + "&" + redirectUri); - httpcontext.Response.Redirect(authUri); + loginHint: authenticateRequest.IdentityId.HasValue ? + await authenticateRequest.Options.FindEmailFromIdentity( + new EmailDiscoveryRequest + { + HttpContext = authenticateRequest.HttpContext, + IdentityId = authenticateRequest.IdentityId.Value, + ServiceProvider = authenticateRequest.ServiceProvider + }):null, + state: callbackurl.GetLeftPart(UriPartial.Path)); + + authenticateRequest.HttpContext.Response.Redirect(authUri); + + return new OnAuthenticateResult { Success = true }; + } + + private async Task ValidateMicrosoftEntraIdUser(OnCallbackRequest request, Guid handleid, JsonWebToken jwtSecurityToken) + { + + + + + + var user = await _options.Value.FindIdentityAsync(request, jwtSecurityToken.Claims); + + + + var identity = new ClaimsIdentity(new[] + { + + new Claim(IdentityModel.JwtClaimTypes.Subject, user.ToString()), + }, "MicrosoftEntraId"); + + return new ClaimsPrincipal(identity); + + } - public async Task<(ClaimsPrincipal, string, string)> OnCallback(HttpContext httpcontext) + public override async Task PopulateCallbackRequest(OnCallbackRequest request) { - var m = new IdentityModel.Client.AuthorizeResponse(await new StreamReader(httpcontext.Request.Body).ReadToEndAsync()); - var state = m.State.Split(new char[] { '&' }, 2); - var handleId = state[0]; - var redirectUri = state[1]; - var callbackUri = $"{httpcontext.Request.Scheme}://{httpcontext.Request.Host}{httpcontext.Request.Path}"; + var m = new IdentityModel.Client.AuthorizeResponse(await new StreamReader(request.HttpContext.Request.Body).ReadToEndAsync()); + var query = QueryHelpers.ParseNullableQuery(m.State); + + if (query.TryGetValue("token", out var handleid)) + { + request.HandleId = new Guid(handleid); + } + request.Props.Add("code", m.Code); + request.Props.Add("state", m.State); + + + } + public override async Task OnCallback(OnCallbackRequest callbackRequest) + { + var httpcontext= callbackRequest.HttpContext; + + + var http = _clientFactory.CreateClient(); var response = await http.RequestAuthorizationCodeTokenAsync(new AuthorizationCodeTokenRequest { Address = _options.Value.GetMicrosoftTokenEndpoint(httpcontext), ClientId = _options.Value.ClientId, ClientSecret = _options.Value.ClientSecret, - Code = m.Code, - RedirectUri = callbackUri, + Code = callbackRequest.Props["code"], + RedirectUri = callbackRequest.Props["state"], }); - ClaimsPrincipal identity = await _options.Value.ValidateUserAsync(httpcontext, handleId, response); + var handler = new JsonWebTokenHandler(); + + var jwtSecurityToken = handler.ReadJsonWebToken(response.IdentityToken); + + + ClaimsPrincipal identity = await ValidateMicrosoftEntraIdUser(callbackRequest, callbackRequest.HandleId, jwtSecurityToken); + if (identity == null) { - httpcontext.Response.Redirect($"{httpcontext.Request.Scheme}://{httpcontext.Request.Host}callback?error=access_denied&error_subcode=user_not_found"); - //return; + return new OnCallBackResult { ErrorCode = "access_denied", ErrorSubCode = "user_validation_failed", ErrorMessage = "User could not be validated", Success = false }; + } - var handler = new JwtSecurityTokenHandler(); - var jwtSecurityToken = handler.ReadJwtToken(response.IdentityToken); - - // Get string of group claims from the token + var groupClaims = jwtSecurityToken.Claims.Where(c => c.Type == "groups"); - if (!groupClaims.Any()) + + if (!string.IsNullOrEmpty(_options.Value.GroupId)) { - httpcontext.Response.Redirect($"{httpcontext.Request.Scheme}://{httpcontext.Request.Host}callback?error=access_denied&error_subcode=group_not_found"); - //return; + + if (!groupClaims.Any(x=>x.Value == _options.Value.GroupId)) + { + return new OnCallBackResult { ErrorCode = "access_denied", ErrorSubCode = "user_access_group_missing", ErrorMessage = "User does not have access", Success = false }; + + } } - // Get the group ids from the claims + var groupIds = groupClaims.Select(c => new Guid(c.Value)).ToList(); var db = httpcontext.RequestServices.GetRequiredService>(); await SyncUserGroup(identity, groupIds, db); - return (identity, redirectUri, handleId); + return new OnCallBackResult { Principal = identity, Success = true }; } private async Task SyncUserGroup(ClaimsPrincipal identity, List groupIds, EAVDBContext db) { - var claimDict = identity.Claims.ToDictionary(c => c.Type, c => c.Value); - var userId = new Guid(claimDict["sub"]); + var identityId = Guid.Parse(identity.FindFirstValue("sub")); // Fetch all security group members for user var groupMembersQuery = db.Set() - .Where(sgm => sgm.IdentityId == userId); + .Where(sgm => sgm.IdentityId == identityId); // Fetch in memory var groupMembersDict = await groupMembersQuery.ToDictionaryAsync(sgm => sgm.Id); @@ -134,7 +191,7 @@ private async Task SyncUserGroup(ClaimsPrincipal identity, List groupIds, if (!sgmGroupSpecific.Any(sgm => sgm.SecurityGroupId == sg.Id)) { var sgm = new TSecurityGroupMember(); - sgm.IdentityId = userId; + sgm.IdentityId = identityId; sgm.SecurityGroupId = sg.Id; db.Add(sgm); diff --git a/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraIdEasyAuthExtensions.cs b/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraIdEasyAuthExtensions.cs index bdae08a..06417bd 100644 --- a/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraIdEasyAuthExtensions.cs +++ b/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraIdEasyAuthExtensions.cs @@ -1,5 +1,6 @@ using EAVFramework; using EAVFramework.Configuration; +using EAVFramework.Extensions; using EAVFW.Extensions.SecurityModel; using IdentityModel.Client; using Microsoft.AspNetCore.Builder; @@ -8,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using System; +using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; @@ -23,17 +25,20 @@ public static class MicrosoftEntraIdEasyAuthExtensions { - public static AuthenticatedEAVFrameworkBuilder AddMicrosoftEntraIdEasyAuth( + public static AuthenticatedEAVFrameworkBuilder AddMicrosoftEntraIdEasyAuth( this AuthenticatedEAVFrameworkBuilder builder, - Func> validateUserAsync, + Func, Task> findIdentityAsync, Func getMicrosoftAuthorizationUrl , Func getMicrosoftTokenEndpoint) + where TContext : DynamicContext + where TIdentity: DynamicEntity,IIdentity where TSecurityGroup : DynamicEntity, IEntraIDSecurityGroup where TSecurityGroupMemeber : DynamicEntity, ISecurityGroupMember, new() { - builder.AddAuthenticationProvider, MicrosoftEntraIdEasyAuthOptions,IConfiguration>((options, config) => + builder.AddAuthenticationProvider, + MicrosoftEntraIdEasyAuthOptions,IConfiguration>((options, config) => { config.GetSection("EAVEasyAuth:MicrosoftEntraId").Bind(options); - options.ValidateUserAsync = validateUserAsync; + options.FindIdentityAsync = findIdentityAsync; options.GetMicrosoftAuthorizationUrl = getMicrosoftAuthorizationUrl; options.GetMicrosoftTokenEndpoint = getMicrosoftTokenEndpoint; @@ -43,16 +48,18 @@ public static AuthenticatedEAVFrameworkBuilder AddMicrosoftEntraIdEasyAuth( + public static AuthenticatedEAVFrameworkBuilder AddMicrosoftEntraIdEasyAuth( this AuthenticatedEAVFrameworkBuilder builder, - Func> validateUserAsync) + Func, Task> findIdentityAsync) + where TContext : DynamicContext + where TIdentity : DynamicEntity, IIdentity where TSecurityGroup : DynamicEntity, IEntraIDSecurityGroup where TSecurityGroupMemeber : DynamicEntity, ISecurityGroupMember,new() { - builder.AddAuthenticationProvider, MicrosoftEntraIdEasyAuthOptions, IConfiguration>((options, config) => + builder.AddAuthenticationProvider, MicrosoftEntraIdEasyAuthOptions, IConfiguration>((options, config) => { config.GetSection("EAVEasyAuth:MicrosoftEntraId").Bind(options); - options.ValidateUserAsync = validateUserAsync; + options.FindIdentityAsync = findIdentityAsync; }); builder.Services.AddScoped>(); diff --git a/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraIdEasyAuthOptions.cs b/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraIdEasyAuthOptions.cs index f5df8a5..15b3f73 100644 --- a/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraIdEasyAuthOptions.cs +++ b/src/EAVFW.Extensions.EasyAuth.MicrosoftEntraId/MicrosoftEntraIdEasyAuthOptions.cs @@ -1,24 +1,46 @@ +using EAVFramework; +using EAVFramework.Endpoints; +using EAVFramework.Extensions; +using EAVFW.Extensions.SecurityModel; using IdentityModel.Client; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using System; +using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; namespace EAVFW.Extensions.EasyAuth.MicrosoftEntraId { public class MicrosoftEntraIdEasyAuthOptions + { + /// + /// The Entra Client ID (applicationid) used to authenticate with EntraID + /// public string ClientId { get; set; } + /// + /// The Entra Client Secret used to authenticate with EntraID + /// public string ClientSecret { get; set; } + /// + /// The Entra Tenant ID used to authenticate with EntraID, if not provided the common tenant is used (multitenant signin) + /// public string TenantId { get; set; }= "common"; + /// + /// If provided the user should be part of this groupid to be given access + /// public string GroupId { get; set; } - public string Scope { get; set; } = "openid email"; + + + public string Scope { get; set; } = "openid email profile"; public Func GetMicrosoftAuthorizationUrl { get; set; } = DefaultGetMicrosoftAuthorizationUrl; public Func GetMicrosoftTokenEndpoint { get; set; } = DefaultGetMicrosoftTokenEndpoint; - public Func> ValidateUserAsync { get; set; } + public Func, Task> FindIdentityAsync { get; set; } + + // public Func> ValidateUserAsync { get; set; } private static string DefaultGetMicrosoftAuthorizationUrl(HttpContext context) { @@ -32,5 +54,7 @@ private static string DefaultGetMicrosoftTokenEndpoint(HttpContext context) if (options.Value.TenantId == null) throw new Exception("TenantId is not configured"); return $"https://login.microsoftonline.com/{options.Value.TenantId}/oauth2/v2.0/token"; } + + } } \ No newline at end of file