aspnetcore icon indicating copy to clipboard operation
aspnetcore copied to clipboard

Styling Identity Razor Pages to use default Blazor theme is insanely difficult

Open akhanalcs opened this issue 2 years ago • 2 comments

Issue description

I just added Identity to my existing Blazor Server project using Identity Scaffolding. I followed this Microsoft learn guide here.

However, since Identity uses Razor pages instead of Razor components, the styling of the UI changes when a visitor navigates between Identity pages and components.

For eg: The page initially loads like this:

And when the user hits the Login link at the top right corner, the login page opens which looks so ugly 😩:

To fix that, I tried to follow this guide at Microsoft learn. But I was hit with this error (code link here):

The name 'Engine' does not exist in the current context

After few days of pulling hair, I found the answer to my question here: https://stackoverflow.com/a/74408719/8644294

Now, I'm hit with a different issue, i.e. _Layout.cshtml can't find the partial view. image

As you can see, the _LoginPartial.cshtml is inside ~/Features/Shared/Layout/_LoginPartial.cshtml. image

I tried it again by supplying .cshtml file name extension which should force it to look for partial views in the same folder as mentioned here.

It says it didn't even look at any locations 😳: image

After spending hours upon hours on this, I removed the check altogether and just went like this: image

Now the page loads, but the UI looks all messed up: image

I thought the whole point of this was to make the layout look like the first picture.

Conclusion

I think the Identity documentation isn't organized properly and seriously lacking. To do something this simple, I've wasted days and still haven't been able to complete it. If I can suggest something, how about Microsoft creates simple working code sample for all the Auth scenarios in the documentation? That way if the directions in the documentations don't work, we can take a look at the provided sample code.

Even though this has been mentioned a lot before, I still would like to reiterate it: Please support Identity in Blazor without having to resort to these Razor Pages and all the added complexity.

Link to public reproduction project repository

https://github.com/affableashish/blazor-server-auth

Target framework

  • [x] .NET Core
  • [ ] .NET Framework
  • [ ] .NET Standard
dotnet --info output or About VS info
C:\Users\Ashish>dotnet --info
.NET SDK (reflecting any global.json):
 Version:   6.0.301
 Commit:    43f9b18481

Runtime Environment:
 OS Name:     Windows
 OS Version:  10.0.19044
 OS Platform: Windows
 RID:         win10-x64
 Base Path:   C:\Program Files\dotnet\sdk\6.0.301\

Host (useful for support):
  Version: 6.0.6
  Commit:  7cca709db2

.NET SDKs installed:
  5.0.300 [C:\Program Files\dotnet\sdk]
  6.0.301 [C:\Program Files\dotnet\sdk]

.NET runtimes installed:
  Microsoft.AspNetCore.All 2.1.28 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All]
  Microsoft.AspNetCore.All 2.1.30 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All]
  Microsoft.AspNetCore.App 2.1.28 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 2.1.30 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 3.1.15 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 3.1.26 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 5.0.6 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 5.0.17 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 6.0.6 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.NETCore.App 2.1.28 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 2.1.30 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 3.1.15 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 3.1.26 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 5.0.6 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 5.0.17 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.6 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.WindowsDesktop.App 3.1.15 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 3.1.26 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 5.0.6 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 5.0.17 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 6.0.6 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]

To install additional .NET runtimes or SDKs:
  https://aka.ms/dotnet-download

akhanalcs avatar Nov 18 '22 18:11 akhanalcs

cc @danroth27

akhanalcs avatar Nov 18 '22 18:11 akhanalcs

Hello everyone 👋, I worked on this and figured out a solution that works. 😌 Please do a review of it 🕵️‍♂️ and let me know if how I'm doing it is a good practice or not. If not, please give me pointers on how to improve it.

If you guys think it's good, I can even contribute to the MSFT documentation. (Just tell me how to do it)


In summary, I took MainLayout.razor and put it inside _Layout.cshtml.

Step 1:

Since we're using NavMenu and LoginDisplay razor components inside _Layout.cshtml, add these to the top of _Layout.cshtml:

@using HMT.Web.Server.Features.Shared.Layout.NavMenu
@using HMT.Web.Server.Features.Identity

Step 2:

Replace <body> with this:

<body>
    <div class="page">
        <div class="sidebar">
            <component type="typeof(NavMenu)" render-mode="ServerPrerendered" />
        </div>

        <main>
            <div class="top-row px-4 logindisplay">
                <component type="typeof(LoginDisplay)" render-mode="ServerPrerendered" />
            </div>

            <article class="content px-4">
                @RenderBody()
            </article>

            <div class="bottom-row px-4">
                <a href="">
                    <img src="/images/logo-black.png" width="40"/>
                    <span class="fs-4">HMT</span>
                </a>
            </div>

            <div id="blazor-error-ui">
                <environment include="Staging,Production">
                    An error has occurred. This application may no longer respond until reloaded.
                </environment>
                <environment include="Development">
                    An unhandled exception has occurred. See browser dev tools for details.
                </environment>
                <a href="" class="reload">Reload</a>
                <a class="dismiss">🗙</a>
            </div>
        </main>
    </div>

    <script src="~/Identity/lib/jquery/dist/jquery.js"></script>
    <script src="~/Identity/lib/bootstrap/dist/js/bootstrap.bundle.js"></script>
    <script src="~/Identity/js/site.js" asp-append-version="true"></script>
    @RenderSection("Scripts", required: false)
    <script src="_framework/blazor.server.js"></script>
</body>

If you're thinking that I just cut and pasted <div class="page"> section from MainLayout.razor, you're absolutely right.😁

Step 3:

Create: Features/Shared/Layout/_Layout.cshtml.css file. Now cut and paste all the css styles from your MainLayout.razor.css to _Layout.cshtml.css. For reference, this is how mine looks like.

Step 4:

All that remains inside MainLayout.razor is this:

@inherits LayoutComponentBase
<PageTitle>Handy Man's Tool</PageTitle>
@Body

Step 5:

Wrap AuthorizeView in LoginDisplay.razor with <CascadingAuthenticationState>. The reason is that if you try to use a Razor Component that has Authorize components from component that IS NOT wrapped with <CascadingAuthenticationState>, it won't work, That is the reason why <component type="typeof(LoginDisplay)" render-mode="ServerPrerendered" /> doesn't work in _Layout.cshtml without it.

Step 6:

If you want to protect the NavLinks in NavMenu, wrap them inside AuthorizeView, and since NavMenu is rendered from _Layout.cshtml, don't forget to wrap them inside <CascadingAuthenticationState>. For eg: Mine looks like this:

<CascadingAuthenticationState>
    <div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
        <nav class="flex-column">
            <div class="nav-item px-3">
                <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                    <span class="oi oi-home" aria-hidden="true"></span> Home
                </NavLink>
            </div>
            <AuthorizeView>
                <div class="nav-item px-3">
                    <NavLink class="nav-link" href="counter">
                        <span class="oi oi-plus" aria-hidden="true"></span> Counter
                    </NavLink>
                </div>
                <div class="nav-item px-3">
                    <NavLink class="nav-link" href="fetchdata">
                        <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
                    </NavLink>
                </div>
            </AuthorizeView>
        </nav>
    </div>
</CascadingAuthenticationState>

Step 7:

Remove the following files as they're unnecessary:

  1. Areas/Identity/Pages/_ValidationScriptsPartial.cshtml (as I'm not overriding it.)
  2. Features/Shared/Layout/_ViewImports.cshtml
  3. Features/Shared/Layout/_LoginPartial.cshtml (Using LoginDisplay instead)

Full source code

https://github.com/affableashish/blazor-server-auth/tree/feature/LayoutWithIdentityPages

Demo

Unauthorized View image

Login Page image

Authorized View image

akhanalcs avatar Nov 23 '22 21:11 akhanalcs

Also, the documentation misses talking about RevalidatingServerAuthenticationStateProvider.

So let me document that here as well: Basically RevalidatingServerAuthenticationStateProvider helps with revalidating the AuthenticationState without having to refresh the page which is especially useful for Single Page Apps. For eg: You open 2 browser windows of the app and you log out from one browser window, at this time you'll still be logged in the second browser window if you don't refresh the page (in the second browser window). This poses a security risk. The solution to that is using RevalidatingServerAuthenticationStateProvider which periodically (using RevalidationInterval) checks the SecurityStamp of user in AuthenticationState against Identity database.

Step 1:

Create a file: Areas/Identity/RevalidatingIdentityAuthenticationStateProvider.cs

using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using System.Security.Claims;

namespace HMT.Web.Server.Areas.Identity
{
    public class RevalidatingIdentityAuthenticationStateProvider<TUser>
        : RevalidatingServerAuthenticationStateProvider where TUser : class
    {
        private readonly IServiceScopeFactory _scopeFactory;
        private readonly IdentityOptions _options;

        public RevalidatingIdentityAuthenticationStateProvider(
            ILoggerFactory loggerFactory,
            IServiceScopeFactory scopeFactory,
            IOptions<IdentityOptions> optionsAccessor)
            : base(loggerFactory)
        {
            _scopeFactory = scopeFactory;
            _options = optionsAccessor.Value;
        }

        protected override TimeSpan RevalidationInterval => TimeSpan.FromSeconds(30);

        protected override async Task<bool> ValidateAuthenticationStateAsync(AuthenticationState authenticationState, CancellationToken cancellationToken)
        {
            //Get the user manager from a new scope to ensure it fetches fresh data
            var scope = _scopeFactory.CreateScope();

            try
            {
                var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TUser>>();
                return await ValidateSecurityTimeStampAsync(userManager, authenticationState.User);
            }
            finally
            {
                if (scope is IAsyncDisposable asyncDisposable)
                {
                    await asyncDisposable.DisposeAsync();
                }
                else
                {
                    scope.Dispose();
                }
            }
        }

        private async Task<bool> ValidateSecurityTimeStampAsync(UserManager<TUser> userManager, ClaimsPrincipal principal)
        {
            var user = await userManager.GetUserAsync(principal);
            if (user == null)
            {
                return false;
            }
            else if (!userManager.SupportsUserSecurityStamp)
            {
                return true;
            }
            else
            {
                var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType);
                var userStamp = await userManager.GetSecurityStampAsync(user);
                return principalStamp == userStamp;
            }
        }
    }
}

Step 2:

Register it in Program.cs

builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<HMTUser>>();

Important: Place this AFTER builder.Services.AddServerSideBlazor(); line.

Step 3:

Update the security timestamp when user logs out. Go to Areas/Identity/Pages/Account/Logout.cshtml.cs and replace LogoutModel with this:

    public class LogoutModel : PageModel
    {
        private readonly SignInManager<HMTUser> _signInManager;
        private readonly UserManager<HMTUser> _userManager;
        private readonly ILogger<LogoutModel> _logger;

        public LogoutModel(SignInManager<HMTUser> signInManager, UserManager<HMTUser> userManager, ILogger<LogoutModel> logger)
        {
            _signInManager = signInManager;
            _userManager = userManager;
            _logger = logger;
        }

        public async Task<IActionResult> OnPost(string returnUrl = null)
        {
            await _signInManager.SignOutAsync();
            var identity = await _userManager.GetUserAsync(User);
            await _userManager.UpdateSecurityStampAsync(identity);

            _logger.LogInformation("User logged out.");
            if (returnUrl != null)
            {
                return LocalRedirect(returnUrl);
            }
            else
            {
                // This needs to be a redirect so that the browser performs a new
                // request and the identity for the user gets updated.
                return RedirectToPage();
            }
        }
    }

Basically, the only part that I've added here is injecting UserManager and updating security timestamp.

akhanalcs avatar Nov 30 '22 17:11 akhanalcs

Also, the documentation directs the user to create half-complete RedirectToLogin component (because using it as-is causes exceptions with very vague error message) and completely misses taking about how to use it.

So let me document that here as well. (I've also answered this in this Stackoverflow post).

Step 1: Create a razor component named RedirectToLogin.razor wherever you want.

For eg: I'm creating it inside Areas/Identity/Components

And add the following code to this file:

@inject NavigationManager Navigation
@code {
    [Parameter]
    public string ReturnUrl { get; set; }

    protected override async Task OnInitializedAsync()
    {
            ReturnUrl = "~/" + ReturnUrl;
            Navigation.NavigateTo("Identity/Account/Login?returnUrl=" + ReturnUrl, true);
            await base.OnInitializedAsync();
    }
}

Step 2: Use this component from App.razor inside <NotAuthorized></NotAuthorized>:

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (!context.User.Identity.IsAuthenticated)
                    {
                        <RedirectToLogin ReturnUrl="@Navigation.ToBaseRelativePath(Navigation.Uri)" />
                    }
                    else
                    {
                        <p role="alert">Sorry, you're not authorized to view this page.</p>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
            <FocusOnNavigate RouteData="@routeData" Selector="h1" />
        </Found>
        <NotFound>
            <PageTitle>Not found</PageTitle>
            <LayoutView Layout="@typeof(MainLayout)">
                <p role="alert">Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Complete source code

https://github.com/affableashish/blazor-server-auth/tree/feature/LayoutWithIdentityPages

Demo

Start the app and try to get to an authorized view from the browser (Counter page here as an example):

You'll be redirected to Login page:

You'll log in successfully and get to the Counter page:

akhanalcs avatar Dec 12 '22 18:12 akhanalcs

Thank you!

ghadzhigeorgiev avatar Dec 16 '22 16:12 ghadzhigeorgiev

Hello @affableashish ... Last week, I finally 😅 reached the Blazor Security node of articles to pick back up with updates. Some work was performed prior to .NET 7's release last year. I had to shift my focus over to .NET 7 content. Lately, I've finished working through '22 backlog issues. I've already addressed some of your remarks, and I'll give you a quick thumbnail here of where things are at today.

Styling

"To fix that, I tried to follow this guide at Microsoft learn.

It used to work in earlier versions, and it doesn't surprise me that it 💥 at some point 😈 along the way with all of the framework churn. I have a tracking entry to work up (at least for .NET 7) updates for the section. The PU might be planning something to address it in the framework. Even if not, I'll have updated guidance when I reach the article for work.

As you may have guessed from ...

The name 'Engine' does not exist in the current context

... the PU ("product unit" btw 😉) sent that over for immediate publication at the time. That will be addressed with my next round of updates. Thanks for calling it out.

Covering ALL auth scenarios in sample apps

If I can suggest something, how about Microsoft creates simple working code sample for all the Auth scenarios in the documentation?

We haven't been able to cover them that way because there are too many scenarios, especially for the staffing level that we have ... only ME 🦖😄 on the docs side. With so much framework churn, sample apps are challenging to keep current each release. We do need to cover the main scenarios/use cases, but the vast majority of the coverage will be in code in the Security node articles and not via sample apps. That's subject to change in the future as vast segments of the Blazor framework stabilize.

The PU is great about letting me know that they're seeing the same question across issues and that we need to add coverage (or fix coverage 🚑🚒🚓). They'll distill down what we need to cover. We'll try to provide as many cut-'n-paste/working code snippets as we can, and then we'll leave the rest for the community to work out in their own apps. We'll try to make sure that the API is covered for what devs are working on.

RevalidatingServerAuthenticationStateProvider

... and ServerAuthenticationStateProvider for that matter. I just added a bit to help devs understand what these are, where they're covered in the API docs, and where they can find them in reference source: Blazor Security overview: Blazor Server authentication. [NOTE: I'm moving that coverage over to a new section Additional security abstractions in the Blazor Server article; so if the other link breaks, use that one. This is all very much a WIP right now 🏃] The PU will review and advise further on those remarks later. Idk at this time if they'll want to carry sample code. Thus far, the implicit answer is, "No thanks!" 😄 They haven't asked for it over the years. They have told devs in PU issues that these are available for inspection (for customization) and for direct use. We'll see tho how it plays out after I speak with them further about this.

Blazor Identity components

Please support Identity in Blazor without having to resort to these Razor Pages and all the added complexity.

... a regular ask ... at least in the olden days of Blazor 👴. We call out the detail at https://learn.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity?view=aspnetcore-7.0&tabs=visual-studio#custom-identity-components. That guidance will also be reviewed/updated shortly.

RedirectToLogin component

It's been covered in the Blazor WebAssembly security topics from the start because such a component was provided by the Blazor WASM template with auth enabled. That coverage was updated for .NET 7 (i.e., navigation history state). I just added additional coverage for it, but it was for Blazor WebAssembly apps. The scaffolding topic's section does require an update for .NET 7's navigation history state AFAIK. I don't think what's there will break 🤞, but that's not the latest/best approach now. Updates will take place when I reach the topic shortly.

Conclusion

Thanks for reading my BOOK! 🙈🤣 But yeah, I'm working this node now after quite a while since the last round of major updates, and I think most of what you're asking for will either be covered, will have its coverage improved, be addressed in future framework releases (e.g., better match on styling, perhaps), or be considered won't-fix scenarios for docs, where devs will need to work out their custom scenarios on their own with community assistance and our nuts-'n-bolts API coverage with reference source.

Feel free to open issues from the bottom of any topic with the This page feedback form, but note that I'm currently tracking many issues for analysis and updates via https://github.com/dotnet/AspNetCore.Docs/issues/28001. I may close an issue that you open and place a tracking entry on that issue to collapse the work requests down to just the one issue.

guardrex avatar Feb 13 '23 16:02 guardrex

Hi @guardrex 👋, Thank you for your response. 😄

I figured out 📏📐 solution to the issues I've raised here. Also, I created a sample app for different auth scenarios in a Blazor server app. https://github.com/affableashish/blazor-server-auth I'll always keep the repo public 👀 so anyone can take a look at it if needed.

I'm glad that these things will improve in the future. As for ALL examples, maybe I was getting too enthused at the moment lol 😂, but covering typical scenarios should be enough. For eg: Exampler elated to how signup, login, logout works in Blazor Server, WebAssembly, .NET MVC or how services should authenticate. I like how David has covered auth in his sample Blazor WASM app: https://github.com/davidfowl/TodoApi

I agree that can be a lot of work, in which case having docs as comprehensive as possible should be enough. That way we can put together any scenario we need.

You can close this issue whenever you'd like. 😊

akhanalcs avatar Feb 13 '23 17:02 akhanalcs

Sure thing, @affableashish. I'm sorry that I didn't reach the whole subject as planned ... I was going to do all of this work in February/March of 2022. That was the plan, and you see how well that went! 😩 I was clobbered with lots of other issues that had higher priority. Once we reached the work for .NET 7, there was no way to get into the weeds with it in 2022. That's why at this point when you look at the UE pass tracking section of https://github.com/dotnet/AspNetCore.Docs/issues/28001 you see all of those BLUE links in the Security node part of the list. Those are all kinds of issues that have been opened pertaining to asks for authn/z doc updates that weren't actually bugs. They were piling up in the prior year (2021), and that became worse throughout 2022. I'm glad to have reached it this year ... right now ... and even working on it at this very instant.

I will take a look at everything you've posted here and the cross-links that you just provided :point_up:. It's up to PU if they'd like to leave this issue open. I focus on the docs side of things and take care of the issues in the docs repo.

Thanks again for all of your input and ideas. If everything goes well, I think that the work on the Security node will be complete within a month or two. It just depends on the workload. You'll see a number of improvements. Again, please open a docs issue whenever you spot something for potential improvement ... and, of course, for outright 🐞 fixes 😈 ... from the bottom of any topic. Issues on the docs repo for Blazor come directly to me, and I'll get back to you ASAP 🏃.

guardrex avatar Feb 13 '23 18:02 guardrex

Thank you for contacting us. Due to a lack of activity on this discussion issue we're closing it in an effort to keep our backlog clean. If you believe there is a concern related to the ASP.NET Core framework, which hasn't been addressed yet, please file a new issue.

This issue will be locked after 30 more days of inactivity. If you still wish to discuss this subject after then, please create a new issue!

ghost avatar Apr 14 '23 20:04 ghost