Will Parsons
Will Parsons

Reputation: 311

Blazor server side - redirecting if unauthenticated before the page starts loading

Might be trying to solve this the wrong way, but here's the situation.

I have a component designed to redirect to login if the user trying to access it isn't authenticated, and display not found if they aren't authorized for the page requested.

<AuthorizeViewWithPermissions RequiredPermission="RequiredPermission">
    <Authorized>
        @ChildContent
    </Authorized>
    <NotAuthenticated>
        <LoginRedirect />
    </NotAuthenticated>
    <NotAuthorized>
        <NotFoundRedirect />
    </NotAuthorized>
</AuthorizeViewWithPermissions>

@code {
    [Parameter] public RenderFragment ChildContent { get; set; }

    [Parameter] public Permissions RequiredPermission { get; set; }

    protected override void OnInitialized()
    {

    }
}

LoginRedirect is this:

    public class LoginRedirect : ComponentBase
    {
        [Inject] protected NavigationManager NavigationManager { get; set; }

        protected override void OnInitialized()
        {
            NavigationManager.NavigateTo("/Login", true);
        }
    }

internals of AuthorizeViewWithPermissions:

    /// <summary>
    /// Largely borrowed from the original AuthorizeView, but cut up a bit to use custom permissions and cut out a lot of stuff that isn't needed.
    /// </summary>
    public class AuthorizeViewWithPermissions : ComponentBase
    {
        private AuthenticationState _currentAuthenticationState;

        private bool _isAuthorized;

        private bool _isAuthenticated;

        /// <summary>
        /// The permission type required to display the content
        /// </summary>
        [Parameter] public Permissions RequiredPermission { get; set; }

        /// <summary>
        /// The content that will be displayed if the user is authorized.
        /// </summary>
        [Parameter] public RenderFragment<AuthenticationState> ChildContent { get; set; }

        /// <summary>
        /// The content that will be displayed if the user is not authorized.
        /// </summary>
        [Parameter] public RenderFragment<AuthenticationState> NotAuthorized { get; set; }

        /// <summary>
        /// The content that will be displayed if the user is not authenticated.
        /// </summary>
        [Parameter] public RenderFragment<AuthenticationState> NotAuthenticated { get; set; }

        /// <summary>
        /// The content that will be displayed if the user is authorized.
        /// If you specify a value for this parameter, do not also specify a value for <see cref="ChildContent"/>.
        /// </summary>
        [Parameter] public RenderFragment<AuthenticationState> Authorized { get; set; }

        /// <summary>
        /// The content that will be displayed while asynchronous authorization is in progress.
        /// </summary>
        [Parameter] public RenderFragment Authorizing { get; set; }

        /// <summary>
        /// The resource to which access is being controlled.
        /// </summary>
        [Parameter] public object Resource { get; set; }

        [CascadingParameter] private Task<AuthenticationState> AuthenticationState { get; set; }

        /// <inheritdoc />
        protected override void BuildRenderTree(RenderTreeBuilder builder)
        {
            // We're using the same sequence number for each of the content items here
            // so that we can update existing instances if they are the same shape
            if (_currentAuthenticationState == null)
            {
                builder.AddContent(0, Authorizing);
            }
            else if (_isAuthorized)
            {
                var authorized = Authorized ?? ChildContent;
                builder.AddContent(0, authorized?.Invoke(_currentAuthenticationState));
            }
            else if (!_isAuthenticated)
            {
                builder.AddContent(0, NotAuthenticated?.Invoke(_currentAuthenticationState));
            }
            else
            {
                builder.AddContent(0, NotAuthorized?.Invoke(_currentAuthenticationState));
            }
        }

        /// <inheritdoc />
        protected override async Task OnParametersSetAsync()
        {
            // We allow 'ChildContent' for convenience in basic cases, and 'Authorized' for symmetry
            // with 'NotAuthorized' in other cases. Besides naming, they are equivalent. To avoid
            // confusion, explicitly prevent the case where both are supplied.
            if (ChildContent != null && Authorized != null)
            {
                throw new InvalidOperationException($"Do not specify both '{nameof(Authorized)}' and '{nameof(ChildContent)}'.");
            }

            if (AuthenticationState == null)
            {
                throw new InvalidOperationException($"Authorization requires a cascading parameter of type Task<{nameof(AuthenticationState)}>. Consider using {typeof(CascadingAuthenticationState).Name} to supply this.");
            }

            // First render in pending state
            // If the task has already completed, this render will be skipped
            _currentAuthenticationState = null;

            // Then render in completed state
            // Importantly, we *don't* call StateHasChanged between the following async steps,
            // otherwise we'd display an incorrect UI state while waiting for IsAuthorizedAsync
            _currentAuthenticationState = await AuthenticationState;
            SetAuthorizedAndAuthenticated(_currentAuthenticationState.User);
        }

        private void SetAuthorizedAndAuthenticated(ClaimsPrincipal user)
        {
            var userWithData = SessionHelper.GetCurrentUser(user);

            _isAuthenticated = userWithData != null;
            _isAuthorized = userWithData?.Permissions.Any(p => p == RequiredPermission || p == Permissions.SuperAdmin) ?? false;
        }
    }

The authentication and authorization checks are working perfectly fine, but the issue is that the page OnInitializedAsync or OnParametersSetAsync fire before the LoginRedirect OnInitialized.

I'm kicking off my calls for data (to an API, that uses a token stored on the logged in user's data) in OnInitializedAsync which results in it attempting to load the data (with no auth token) instead of just redirecting. If I comment out the data call the redirect works as expected without issue, so it's just a timing/sequence of events issue.

Is there a solution to this? Should I just be changing my api client code to silently fail instead of throwing an unauthorized exception if the auth token is missing?

This is also my app.razor component:

CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    <LoginRedirect />
                </NotAuthorized>
                <Authorizing>
                    <p>Checking authorization...</p>
                </Authorizing>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>These aren't the droids you're looking for.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Upvotes: 5

Views: 4073

Answers (3)

Ruina
Ruina

Reputation: 365

Originally, I found a tutorial online that recommends using a custom route view, however that would not allow me to perform any asynchronous calls to determine if the user was logged in.

So I found another solution that didn't require me to create a custom component or add code to every page.

Instead, I just added some code to my Main Layout and it works like a charm:

@using System.Net
@layout TelerikLayout

@inherits LayoutComponentBase
@inject NavigationManager NavigationManager;
@inject IIdentityService IdentityService;

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <div class="main" style="height: 100vh; display: flex; flex-direction: column;">
        <div class="top-row px-4">
            <a onclick="@LogOut" target="_blank">Log Out</a>
        </div>

        <div class="content px-4" style="flex: 1;">
            @Body
        </div>
    </div>
</div>

@code {
    protected override async Task OnInitializedAsync()
    {
        await CheckLoginState();
    }

    private async Task CheckLoginState()
    {
        bool isloggedin = await IdentityService.IsLoggedIn();
        if (isloggedin == false && NavigationManager.Uri.Split("?").ToList().FirstOrDefault()?.EndsWith("/login") != true)
        {
            var returnUrl = WebUtility.UrlEncode(new Uri(NavigationManager.Uri).PathAndQuery);
            NavigationManager.NavigateTo($"login?returnUrl={returnUrl}");
        }
        await base.OnInitializedAsync();
    }

    private async void LogOut()
    {
        await IdentityService.Logout();
        await CheckLoginState();
    }
}

I have not tested this solution in a production environment, but so far, it seems to be good.

Upvotes: 1

enet
enet

Reputation: 45586

Not being familiar with the internals AuthorizeViewWithPermissions, I can only venture to say that the LoginRedirect component should be exposed from the NotAuthorized property element, as NotAuthorized convey the dual meaning of having no permissions to access a resource, as well as not authenticated. You can be authenticated, but not authorized, and you can be authorized if you are authenticated only (this is the default), unless you define policies to be more specific about your requirements.

In terms of coding, please see how it is done in the default VS template:

 <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
            <NotAuthorized>
                @if (!context.User.Identity.IsAuthenticated)
                {
                    <RedirectToLogin />
                }
                else
                {
                    <p>You are not authorized to access this resource.</p>
                }
            </NotAuthorized>
        </AuthorizeRouteView>

Again, your posted code is partial, and I can only guess... But I think that you have an issue with the design of your AuthorizeViewWithPermissions, related to what I was referring to above. Try to design it otherwise. Don't look for workaround, for this can prove fatal in the long run. Just try to change the design, based on understanding how the system works...

Hope this helps...

Upvotes: 0

agua from mars
agua from mars

Reputation: 17404

Simply check if you have a user before the call to the api:

@inject AuthenticationStateProvider AuthenticationStateProvider

...

@code {
    protected override async Task OnInitializedAsync()
    {
        var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
        var user = authState.User;

        if (!user.Identity.IsAuthenticated)
        {
            return;
        }
    }
}

doc

Upvotes: 2

Related Questions