From 32cafb8d48f67d6eaa02ba958d7a490d96e119c4 Mon Sep 17 00:00:00 2001 From: Tobias Klika Date: Thu, 21 May 2020 13:41:09 +0200 Subject: [PATCH] switching applications works now, YAY! --- zero.Core/Api/{AppIdService.cs => AppId.cs} | 0 zero.Core/Api/ApplicationContext.cs | 283 ++++++++++++++++++ zero.Core/Api/BackofficeStore.cs | 7 +- .../Entities/Applications/Application.cs | 36 ++- .../ApplicationContextMiddleware.cs | 24 ++ zero.Web.UI/App/navigation.vue | 30 +- zero.Web.UI/App/services/auth.js | 6 + .../Controllers/ApplicationsController.cs | 9 + .../Controllers/AuthenticationController.cs | 21 +- zero.Web/ZeroApplicationBuilderExtensions.cs | 3 + zero.Web/ZeroBuilder.cs | 2 + zero.Web/ZeroVue.cs | 8 +- 12 files changed, 411 insertions(+), 18 deletions(-) rename zero.Core/Api/{AppIdService.cs => AppId.cs} (100%) create mode 100644 zero.Core/Api/ApplicationContext.cs create mode 100644 zero.Core/Middlewares/ApplicationContextMiddleware.cs diff --git a/zero.Core/Api/AppIdService.cs b/zero.Core/Api/AppId.cs similarity index 100% rename from zero.Core/Api/AppIdService.cs rename to zero.Core/Api/AppId.cs diff --git a/zero.Core/Api/ApplicationContext.cs b/zero.Core/Api/ApplicationContext.cs new file mode 100644 index 00000000..d3126f0c --- /dev/null +++ b/zero.Core/Api/ApplicationContext.cs @@ -0,0 +1,283 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Identity; +using Raven.Client.Documents; +using Raven.Client.Documents.Session; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using zero.Core.Entities; +using zero.Core.Extensions; +using zero.Core.Identity; +using zero.Core.Options; + +namespace zero.Core.Api +{ + public class ApplicationContext : IApplicationContext + { + /// + public IApplication App { get; protected set; } + + /// + public string AppId { get; protected set; } + + protected IDocumentStore Raven { get; private set; } + + protected IZeroOptions Options { get; private set; } + + protected UserManager UserManager { get; private set; } + + + + public ApplicationContext(IDocumentStore raven, IZeroOptions options, UserManager userManager) + { + Raven = raven; + Options = options; + UserManager = userManager; + } + + + /// + public async Task Resolve(HttpContext context) + { + if (context?.Request == null) + { + return null; + } + + IApplication app; + + if (IsBackofficeRequest(context)) + { + app = await ResolveFromUser(context.User); + } + else + { + app = await ResolveFromRequest(context); + } + + App = app; + AppId = app?.Id; + + return app; + } + + + /// + public async Task TrySwitchForUser(User user, string appId) + { + if (user == null || appId.IsNullOrEmpty()) + { + return false; + } + + string[] allowedAppIds = GetAllowedAppIdsForUser(user); + + bool isMainId = appId.Equals(user.AppId, StringComparison.InvariantCultureIgnoreCase); + bool isAllowedId = allowedAppIds.Contains(appId, StringComparer.InvariantCultureIgnoreCase); + + if (user.IsSuper || isMainId || isAllowedId) + { + user.CurrentAppId = appId; + + IdentityResult updateResult = await UserManager.UpdateAsync(user); + return updateResult.Succeeded; + } + + return false; + } + + + /// + public async Task ResolveFromUser(ClaimsPrincipal user) + { + User userEntity = await UserManager.GetUserAsync(user); + return await ResolveFromUser(userEntity); + } + + + /// + public async Task ResolveFromUser(User user) + { + if (user == null) + { + return null; + } + + string appId = null; + string[] allowedAppIds = GetAllowedAppIdsForUser(user); + + if (!user.CurrentAppId.IsNullOrEmpty()) + { + if (user.IsSuper || allowedAppIds.Contains(user.CurrentAppId, StringComparer.InvariantCultureIgnoreCase)) + { + appId = user.CurrentAppId; + } + else + { + appId = user.AppId; + } + } + else + { + appId = user.AppId; + } + + if (appId.IsNullOrEmpty()) + { + throw new Exception($"User entity ${user.Id} needs a valid AppId"); + } + + using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) + { + return await session.LoadAsync(appId); + } + } + + + /// + public async Task ResolveFromRequest(HttpContext context) + { + return await ResolveFromUri(context.Request.GetEncodedUrl()); + } + + + /// + public async Task ResolveFromUri(string uriString) + { + return ResolveFromUriInternal(new Uri(uriString, UriKind.Absolute), await GetApplications()); + } + + + /// + public async Task ResolveFromUri(Uri uri) + { + return ResolveFromUriInternal(uri, await GetApplications()); + } + + + /// + /// Get matching application from an URI + /// + IApplication ResolveFromUriInternal(Uri uri, IList apps) + { + IApplication currentApp = null; + + foreach (Application app in apps) + { + if (app.Domains?.Length < 1) + { + continue; + } + + foreach (string domain in app.Domains) + { + Uri domainUri = new Uri(domain, UriKind.Absolute); + + int compareResult = Uri.Compare(uri, domainUri, UriComponents.HostAndPort, UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase); + + if (compareResult == 0) + { + currentApp = app; + break; + } + } + } + + return currentApp; + } + + + /// + /// Get all applications to choose from + /// + async Task> GetApplications() + { + using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) + { + return await session.Query().ToListAsync(); + } + } + + + /// + /// Get applications the user has access to + /// + string[] GetAllowedAppIdsForUser(User user) + { + IEnumerable permissions = user.Claims + .Where(claim => claim.Type == Constants.Auth.Claims.Permission && claim.Value.StartsWith(Permissions.Applications)) + .Select(x => Permission.FromClaim(x.ToClaim(), Permissions.Applications)); + + string[] appIds = permissions.Where(x => x.IsTrue).Select(x => x.NormalizedKey).ToArray(); + + return appIds; + } + + + /// + /// Whether the current request is a backoffice request + /// + bool IsBackofficeRequest(HttpContext context) + { + string path = Options.BackofficePath.EnsureStartsWith('/').TrimEnd('/'); + return context.Request.Path.ToString().StartsWith(path); + } + } + + + + public interface IApplicationContext + { + /// + /// Currently loaded application + /// + IApplication App { get; } + + /// + /// Current loaded application Id + /// + string AppId { get; } + + /// + /// Resolves the current application from either the backoffice user (in case it is backoffice request) + /// or the domain (in case it is frontend request). + /// The resolved data is stored in the App + AppId properties. + /// + Task Resolve(HttpContext context); + + /// + /// Try to switch the current application for a user + /// + Task TrySwitchForUser(User user, string appId); + + /// + /// Resolves the current application from the request path + /// + Task ResolveFromRequest(HttpContext context); + + /// + /// Get matching application from an URI string + /// + Task ResolveFromUri(string uriString); + + /// + /// Get matching application from an URI + /// + Task ResolveFromUri(Uri uri); + + /// + /// Resolves the current application from the logged-in backoffice user. + /// This method won't return apps the user has no access to. + /// + Task ResolveFromUser(ClaimsPrincipal user); + + /// + /// Resolves the current application from a user. + /// This method won't return apps the user has no access to. + /// + Task ResolveFromUser(User user); + } +} diff --git a/zero.Core/Api/BackofficeStore.cs b/zero.Core/Api/BackofficeStore.cs index a480ba2e..bb235e78 100644 --- a/zero.Core/Api/BackofficeStore.cs +++ b/zero.Core/Api/BackofficeStore.cs @@ -1,13 +1,11 @@ using FluentValidation; using FluentValidation.Results; -using Microsoft.AspNetCore.Http; using Raven.Client.Documents; using Raven.Client.Documents.Linq; using Raven.Client.Documents.Session; using System; using System.Collections.Generic; using System.Linq; -using System.Security.Claims; using System.Threading.Tasks; using zero.Core.Attributes; using zero.Core.Entities; @@ -239,10 +237,9 @@ namespace zero.Core.Api public class AppAwareBackofficeStore : BackofficeStore, IAppAwareBackofficeStore { - public AppAwareBackofficeStore(IDocumentStore raven, IMediaUpload media, IHttpContextAccessor httpContextAccessor) : base(raven, media) + public AppAwareBackofficeStore(IDocumentStore raven, IMediaUpload media, IApplicationContext context) : base(raven, media) { - Claim appIdClaim = httpContextAccessor.HttpContext?.User.Claims.FirstOrDefault(x => x.Type == Constants.Auth.Claims.CurrentAppId); - AppId = appIdClaim?.Value; + AppId = context.AppId; } } } diff --git a/zero.Core/Entities/Applications/Application.cs b/zero.Core/Entities/Applications/Application.cs index e3d433e3..552fe397 100644 --- a/zero.Core/Entities/Applications/Application.cs +++ b/zero.Core/Entities/Applications/Application.cs @@ -5,37 +5,59 @@ namespace zero.Core.Entities /// /// An application is a website. zero can host multiple websites at once which share common assets /// - public class Application : ZeroEntity, IZeroDbConventions + public class Application : ZeroEntity, IApplication + { + /// + public string FullName { get; set; } + + /// + public string Email { get; set; } + + /// + public string ImageId { get; set; } + + /// + public string IconId { get; set; } + + /// + public string[] Domains { get; set; } = new string[] { }; + + /// + public List Features { get; set; } = new List(); + } + + + public interface IApplication : IZeroEntity, IZeroDbConventions { /// /// Full company or product name /// - public string FullName { get; set; } + string FullName { get; set; } /// /// Generic contact email. Can be used in various locations /// - public string Email { get; set; } + string Email { get; set; } /// /// Image of the application /// - public string ImageId { get; set; } + string ImageId { get; set; } /// /// Simple image of the application (used as favicon) /// - public string IconId { get; set; } + string IconId { get; set; } /// /// All assigned domains for this application /// - public string[] Domains { get; set; } = new string[] { }; + string[] Domains { get; set; } /// /// Features which are enabled for this application. /// Can be user-defined and affect both backoffice and frontend /// - public List Features { get; set; } = new List(); + List Features { get; set; } } } \ No newline at end of file diff --git a/zero.Core/Middlewares/ApplicationContextMiddleware.cs b/zero.Core/Middlewares/ApplicationContextMiddleware.cs new file mode 100644 index 00000000..5c5e4da9 --- /dev/null +++ b/zero.Core/Middlewares/ApplicationContextMiddleware.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using System.Threading.Tasks; +using zero.Core.Api; + +namespace zero.Core.Middlewares +{ + public class ApplicationContextMiddleware + { + RequestDelegate Next; + + + public ApplicationContextMiddleware(RequestDelegate next) + { + Next = next; + } + + + public async Task Invoke(HttpContext httpContext, IApplicationContext appContext) + { + await appContext.Resolve(httpContext); + await Next(httpContext); + } + } +} diff --git a/zero.Web.UI/App/navigation.vue b/zero.Web.UI/App/navigation.vue index d77948df..ab32f9e3 100644 --- a/zero.Web.UI/App/navigation.vue +++ b/zero.Web.UI/App/navigation.vue @@ -5,7 +5,7 @@ @@ -46,7 +46,7 @@