Open Closed

Extending HangfireDashboard #727


User avatar
0
alexander.nikonov created

3.3.2 / Angular

We are extending HangfireDashboard - adding custom tab for handling recurring jobs. As a base, we are about to use existing Github extension (RecurringJobAdmin) which is plumbed like this:

    private void ConfigureHangfire(ServiceConfigurationContext context, IConfiguration configuration)
    {
        context.Services.AddHangfire(globalConfig =>
        {
            globalConfig
                .UseStorage(new OracleStorage(configuration.GetConnectionString("Default")))
                .UseRecurringJobAdmin(typeof(CentralToolsApplicationModule).Assembly);
        });
    }

However here is where the problem is: this extension uses own UI styles and Vue as JS Framework. We don't want to keep using it as a Nuget package. Instead, we would like to have own Module - with the same UI as other pages and get rid of Vue (in favor of Angular or without it). Here's what it looks like:

The questions are:

  1. how to implement this in a way to easily switch to common UI if it is changed globally in the solution? I was looking at ABP IdentityServer - but it has a lot of bundle stuff inside - JS, CSS. Sure not all of this is needed here;
  2. I have seen the way Angular was added to AspNetCore app, it's very cumbersome. And it run a separate Angular server when serving the page - not good too: for comparison, existing RecurringJobAdmin extension uses just a sole Vue.js file to handle this; So is there an easier solution? What we would need though is 'change detection' on Admin page and of course data exchange between client and server part;

25 Answer(s)
  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    I have some questions

    • What is common UI?
    • You want to create a RecurringJobAdmin module right?
  • User Avatar
    0
    alexander.nikonov created

    Hi,

    1. under common UI I mean common design for: Identity Server login page, Angular app and now - for dashboard too. And ability to easily change (once we switch to new CSS) this for all these parts of our solution;

    2. as I mentioned, we already have RecurringJobAdmin (it is slightly modified https://github.com/bamotav/Hangfire.RecurringJobAdmin) and it already works (as an ABP module). What we ask here is how to get rid of Vue which is used in the mentioned RecurringJobAdmin Hangfire extension, but retain interaction with server-side (CRUD for recurring jobs, update UI based on next job run time, etc.)? I've read about SPA package for AspNet Core app, but what confuses me is that it supposes running another instance of Node js, which we don't need, because we already have Angular app running. On other hand, I can't seem to see how to use Angular app to interact with Hangfire dashboard,since it looks like Hangfire dashboard is accessible only on server-side (by means of Middleware in AspNet Core app);

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    Now I know what you want.

    You need to convert the RecurringJobAdmin project into abp module. You can create an angular UI application module and install the module to your application.

  • User Avatar
    0
    alexander.nikonov created

    Sorry, but I am still puzzled and missing the idea how to split server and client logic for Dashboard? Hangfire dashboard pages are based on Razor markup and Dashboard is extended using DashboardRoutes.Routes.AddRazorPage. Since the base class - RazorPage - contains some core functionality (like using a key feature, Dashboard context), I'm afraid I will lose it if switching to Angular page. So, question #1 is: if I make the pages Angular-based instead - how am I supposed to inject them into existing hard-coded Dashboard layout? And question #2 is how am I supposed to interact with server-side extension code? In the given case, server-side part for Hangfire is based on IDashboardDispatcher, which mainly uses Dashboard context and the context is passed through RazorPage class - so all existing logic and even markup is built on server-side... At the same time, we don't want to create a new Hangfire dashboard - we just want to easily extend its features.

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    if I make the pages Angular-based instead - how am I supposed to inject them into existing hard-coded Dashboard layout?

    Just an idea, you can output an html and redirect to the angular page in js:

    internal sealed class AngularPage : PageBase
    {
        public const string Title = "AngularPage";
        public const string PageRoute = "/AngularPage";
    
        private static readonly string PageHtml;
    
        static AngularPage()
        {
            PageHtml = "script window.location.href="angular page url" script";
        }
    
        public override void Execute()
        {
            WriteEmptyLine();
            Layout = new LayoutPage(Title);
            WriteLiteralLine(PageHtml);
            WriteEmptyLine();
        }
    }
    

    There is a lot of work to switch to angular, I think using Vue is a better way.

    how am I supposed to interact with server-side extension code? In the given case, server-side part for Hangfire is based on IDashboardDispatcher, which mainly uses Dashboard context and the context is passed through RazorPage class - so all existing logic and even markup is built on server-side...

    IDashboardDispatcher has been exposed as an endpoint, you can send HTTP requests on the front end, just like RecurringJobAdmin does

    At the same time, we don't want to create a new Hangfire dashboard - we just want to easily extend its features.

    I suggest you continue to use Vue to extend the features, Vue is a progressive framework, you can even use it like jquery.

  • User Avatar
    0
    alexander.nikonov created

    Hi. Thank you - we've estimated the efforts and decided to go on with Vue + Razor pages, as it is supposed to work now in extensions. I've moved Server + Client parts into Nuget packages and now plug-in client part (job manipulation) into our main ABP solution. Important question: are there some difficulties if we want dashboard user to be authenticated in the same way Angular app user is (since dashboard user needs to see only accessible tenants, etc.), i.e. using IdentityServer? Do we need to implement IDashboardAuthorizationFilter? Because by default now we see all jobs being unauthorized. There are several suggestions outthere, we would like to use the simplest for the given case (and if the user is not authenticated - probably we need to redirect him to IdentityServer login form). I've tried the following - but it does not work of course, because obviously when I use location.href from Angular app - whole user information is mising in HttpContext:

    public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
    {
        public bool Authorize([NotNull] DashboardContext context)
        {
            return context.GetHttpContext().User.Identity.IsAuthenticated;
        }
    }
    

    How to pass current authenticated user's information when accessing Hangfire dashboard page from Angular app keeping in mind it's not possible to modify headers when using window.location.href? It's only possible when making HTTP request...

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    You cannot redirect with authorization header. A redirection in the HTTP protocol doesn't support adding any headers to the target location.

    Some possible solutions:

    1. If the servers share a common domain, create a cookie on a domain that spans both (e.g. create cookie on domain.com if login is at auth.domain.com and the app at app.domain.com)

    2. Consider adding it as a query string to the redirect URL. eg window.location.href = "...dashboard?access_token={token}".

      .AddJwtBearer(options =>
          {
              .....
              options.Events = new JwtBearerEvents
              {
                  OnMessageReceived = context =>
                  {
                      var accessToken = context.Request.Query["access_token"];
      
                      // If the request is for our hub...
                      var path = context.HttpContext.Request.Path;
                      if (!string.IsNullOrEmpty(accessToken) &&
                          (path.StartsWithSegments("/hangfire")))
                      {
                          // Read the token out of the query string
                          context.Token = accessToken;
                      }
                      return Task.CompletedTask;
                  }
                  ......
        };
      
  • User Avatar
    0
    alexander.nikonov created

    Hi,

    thanks for the recommendation.

    I now mixed two approaches: when user tries to access "/hangfire" for the first time - he sends access_token in the url, later on, when Hangfire dashboard tries to load the rest of files, access_token is saved to cookies in the same method (OnMessageReceived) from Referer information and from this moment it is used. This way it works OK for Identity. But I am still not sure it makes sense to combine URL approach with Cookies approach or I'd better use solely Cookies from the very beginning? I made the Cookies http only, i.e. not accessible via js, and ssl.

    So if you would suggest to switch to the sole Cookies approach:

    1. where is the best place to create access_token cookie from the very beginning in ABP solution? How to keep it in-sync with Local Storage (default location)?
    2. how to clear cookie on back-end side if user makes logout?
    3. does it make sense to use refresh_token instead to get access_token for working with this dashboard?

    I have tried to use the following code finally, but it does not work: the cookie IS NOT STORED, because as I was explained - cookies cannot be saved when doing AJAX requests. So how to save access token to cookie (and keep them in-sync with current user's access token) using backend code?

                       OnTokenValidated = context =>
                       {
                           if (context.SecurityToken is JwtSecurityToken accessToken && !context.HttpContext.Request.Cookies.ContainsKey(accessTokenCookieName))
                           {
                               context.HttpContext.Response.Cookies.Append(
                                   accessTokenCookieName,
                                   accessToken.RawData,
                                   new CookieOptions
                                   {
                                       Domain = context.HttpContext.Request.Host.Host,
                                       Path = "/",
                                       HttpOnly = true,
                                       SameSite = SameSiteMode.Strict,
                                       Secure = true,
                                       MaxAge = TimeSpan.FromMinutes(60),
                                       IsEssential = true
                                   });
                           }
                           return Task.CompletedTask;
                       },
                       OnAuthenticationFailed = context =>
                       {
                           context.HttpContext.Response.Cookies.Delete(accessTokenCookieName);
                           return Task.CompletedTask;
                       },
                       OnMessageReceived = context =>
                       {
                           if (context.HttpContext.Request.Path.StartsWithSegments("/hangfire"))
                           {
                               if (context.Request.Cookies.TryGetValue(accessTokenCookieName, out string accessToken))
                               {
                                   context.Token = accessToken;
                               }
                           }
                           return Task.CompletedTask;
                       }
    
  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    So if you would suggest to switch to the sole Cookies approach:

    You can use middleware, like:

    app.Use(async (httpContext, func) =>
    {
        var token = httpContext.Request.Headers[HeaderNames.Authorization];
        if (!token.IsNullOrEmpty())
        {
            httpContext.Response.Cookies.Append(HeaderNames.Authorization,
                token.ToString().Replace("Bearer ",""),
                new CookieOptions
               {
                   Domain = <your parent domain>,
                   Path = "/",
                   HttpOnly = true,
                   SameSite = SameSiteMode.Strict,
                   MaxAge = TimeSpan.FromMinutes(60),
                   IsEssential = true
               });
        }
        
        if (httpContext.Request.Path.StartsWithSegments("/account/logout"))
        {
            httpContext.Response.Cookies.Delete(HeaderNames.Authorization);
        }
        
        await func.Invoke();
    });
    

    does it make sense to use refresh_token instead to get access_token for working with this dashboard

    Not recommended, The refresh token should only be used once

  • User Avatar
    0
    alexander.nikonov created

    You can use middleware, like:

    I've already tried to use my code in a middleware - it enters into "if", but in reality cookies not saved. Probably it is because of the mentioned reason, there are limitation, when it is allowed to save cookies to response. Maybe in addition to this approach I need to set up new ClientId / Api Resources (and all that 'rock-n-roll' in IdentityServer admin tab) for Hangfire dashboard?

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    but in reality cookies not saved

    Make sure your blazor and HttpApi at same domain and port. it should be work(I have updated example code).

  • User Avatar
    0
    alexander.nikonov created

    Make sure your blazor and HttpApi at same domain and port. it should be work(I have update example code).

    I've tried it and - no, it does not save cookies, falling into the same "if" each time... In my case, since I'm testing it on local PC, I set domain = "localhost", the Hangfire dashboard is opened as window.open('https://localhost:44328/hangfire') from Angular app (not Blazor) residing at http://localhost:4200 - so ports cannot be the same (CORS between IdentityServer, HttpApiHost and Angular is set up, just in case and Angular app itself works properly). Probably the problem is that Angular app is working via http?

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    If the servers share a common domain, create a cookie on a domain that spans both (e.g. create cookie on domain.com if login is at auth.domain.com and the app at app.domain.com)

    Cookies will be set only when the domain and port are the same. e.g . api.domain.com and angular.domain.com. If you cannot use the same domain and port, you need another way

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    When you log in on the angular client, the auth server will also log in to the current user. I think you can redirect directly without doing anything

  • User Avatar
    0
    alexander.nikonov created

    When you log in on the angular client, the auth server will also log in to the current user. I think you can redirect directly without doing anything

    This is the first thing I have tried to do. Probably it would work, if Angular Dashboard was located as an Angular app page. But as you know, it is a server-side-generated.

    So, Angular resides on http://localhost:4200 and Hangfire Dashboard (since it's a server-side-built) resides on https://localhost:44328 (HttpApiHost, but could be another ApiHost).

    So this simple approach did not work and i now know why: because if we navigate between Angular app pages - we send XmlHttpRequests to get authenticated data back and build markup based on that. We do that adding access_token header everywhere. By this header IdentityServer is able to determine user identity.

    If we do window.location or just go by some link to reach our Dashboard - we cannot add headers. We only can:

    1. send token via URL (bad approach)
    2. have it in cookies by the moment we are checking HttpContext identity (this is what we are discussing now and possibly it will work if Angular app is run via https too, need to check)
    3. (probably will work too and would be the best approach) have CORS manage the things - this last one supposes I need to create a new ClientId / Api Resources and all that stuff especially for Hangfire so IdentityServer would finally redirect us supplying currently authenticated user...
  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    I make an example and hope can help you: https://github.com/realLiangshiwei/Qa727

  • User Avatar
    0
    alexander.nikonov created

    Hi, thank you for the example. But I'm unable to run HttpApiHost, since there is no initial data. What additional steps are required after I run DbMigrator project and created the database?

    And one question: could it be that your example does work (I believe it does :)) and mine - not, because HttpApiHost has IdentityServer integrated in your example and are two separate hosts in our case?

  • User Avatar
    0
    gvnuysal created

    Hi @alexander.nikonov I had the same problem too. Try clearing chrome cache.

  • User Avatar
    0
    alexander.nikonov created

    I had the same problem too. Try clearing chrome cache.

    Oh, you are right - i use localhost for dev env, so i had some tenant id there :) it helped.

    And yes, all in all, identity works as expected for Hangfire page in this simplified configuration. Could you please split HttpApiHost and IdentityServer in your example? I suspect the root cause of my issue could be that. And if nevertheless sending identity continues working, I will have a look what could be different in our configurations.

    I've tried all that I can in my case and the only way out was to create token cookie, as we discussed a bit earlier here. However, if it is possible to make it work without setting the cookie - it will be much better!

    Looking forward for your updated example. Thanks!

    BTW, just in case: we are still using ABP 3.3.2 - we are not ready to update to 4.x. If it is possible, please use the same version in your example.

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    Sorry I forgot your project version, this way only work for projects starting in 4.0.

    Using cookies requires your backend and frontend to be on the same domain&port. Maybe you can use cache to store access_token.

    I can help you remotely. shiwei.liang@volosoft.com

  • User Avatar
    0
    alexander.nikonov created

    Hi,

    Sorry I forgot your project version, this way only work for projects starting in 4.0.

    I would be really grateful if you updated your test project and split IdentityServer and HttpApi.Host part - it will help me to troubleshoot authentication and test new ABP 4.0 things once we upgrade to this version.

    Using cookies requires your backend and frontend to be on the same domain&port. Maybe you can use cache to store access_token.

    I managed to do this using Cookies approach and it seems to work fine. Though, I will show my code here. If you find something not fully correct - please, comment here.

    So, step 1: made Angular web app running via https. Modify SessionRequestHttpInterceptor (pass withCredentials: true):

    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    
            const modified = req.clone({
                setHeaders: { 'Content-Language': this.selectedLangCulture || '' }, withCredentials: true
            });
    
            return next.handle(modified);
        }
    

    step 2: modify appsettings for HttpApi.Host: "AngularApp:HostUrl": "https://localhost:4200", "CorsOrigins": https://localhost:4200"

    step 3: Middleware for HttpApi.Host:

     public static class ApplicationBuilderAccessTokenCookieMiddlewareExtension
        {
            public static IApplicationBuilder UseAccessTokenCookieMiddleware(this IApplicationBuilder app, string cookieName)
            {
                return app.Use(async (httpContext, func) =>
                {
                    string token = null;
                    token = httpContext.Request.Cookies[cookieName];
                    if (token == null)
                    {
                        token = httpContext.Request.Headers[HeaderNames.Authorization].ElementAtOrDefault(0);
                        if (token != null)
                        {
                            token = token.Replace("Bearer ", "");
                        }
                    }
                    if (token != null)
                    {
                        httpContext.Response.Cookies.Append(cookieName,
                            token,
                            new CookieOptions
                            {
                                Path = "/",
                                HttpOnly = true,
                                SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict,
                                MaxAge = TimeSpan.FromMinutes(5),
                                IsEssential = true,
                                Secure = true
                            });
                    }
                    await func.Invoke();
                });
            }
        }
    

    step 4: Middleware for IdentityServer:

    public static class ApplicationBuilderAccessTokenCookieMiddlewareExtension
        {
            public static IApplicationBuilder UseAccessTokenCookieMiddleware(this IApplicationBuilder app, string cookieName)
            {
                return app.Use(async (httpContext, func) =>
                {
                    if (
                        (httpContext.User.Identity?.IsAuthenticated != true || httpContext.Request.Path.StartsWithSegments("/account/logout"))
                        &&
                        httpContext.Request.Cookies[cookieName] != null
                        )
                    {
                        httpContext.Response.Cookies.Delete(cookieName);
                    }
                    await func.Invoke();
                });
            }
        }
    

    step 5: HangfireAuthorizationFilter + corresponding Razor error page:

    public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
        {
            public bool Authorize([NotNull] DashboardContext context)
            {
                var path = context.GetHttpContext().Request.Path;
                var isResource = path != null && (path.Value.StartsWith("/css") || path.Value.StartsWith("/js") || path.Value.StartsWith("/font"));
                if (!context.GetHttpContext().User.Identity.IsAuthenticated && !isResource)
                {
                    context.Response.ContentType = "text/html";
                    context.Response.WriteAsync(new AccessDeniedPage().ToString(context.GetHttpContext()));
                    return false;
                }
                return true;
            }
        }
    
  • User Avatar
    0
    alper created
    Support Team Director

    @alexander did you solve it now?

  • User Avatar
    0
    alexander.nikonov created

    I did by the way i described. But as i mentioned, i would like to have this test version of the project above with Identity and HttpApi Host separated.

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Example project has been updated: https://github.com/realLiangshiwei/Qa727

  • User Avatar
    0
    alexander.nikonov created

    Thank you, @liangshiwei. The test project itself is running well. The issue is discussed and by using the suggested cookie approach it works well too. So I will close the ticket.

Made with ❤️ on ABP v8.2.0-preview Updated on March 25, 2024, 15:11