Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/BootstrapBlazor/BootstrapBlazor.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">

<PropertyGroup>
<Version>11.0.1-beta01</Version>
<Version>11.0.1-beta02</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
30 changes: 23 additions & 7 deletions src/BootstrapBlazor/Components/Layout/Layout.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -729,18 +729,34 @@ public async Task UpdateAsync(string key)

private void Navigation_LocationChanged(object? sender, LocationChangedEventArgs e)
{
if (OnAuthorizing == null)
{
return;
}

if (OnAuthorizing != null)
InvokeAsync(async () =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical · Stale checks overwrite current authorization

When multiple navigations overlap and an earlier OnAuthorizing call completes after a later one, Navigation_LocationChanged applies each result to the shared _authenticated state and may redirect based on the stale location, so the layout shows the wrong content for the current page.

Before applying a result or redirecting, verify that its location is still current.

Also at src/BootstrapBlazor/Components/Layout/Layout.razor.cs:739-758.

Prompt for AI agents
In `src/BootstrapBlazor/Components/Layout/Layout.razor.cs` at line 737:

**Stale checks overwrite current authorization**

When multiple navigations overlap and an earlier `OnAuthorizing` call completes after a later one, `Navigation_LocationChanged` applies each result to the shared `_authenticated` state and may redirect based on the stale location, so the layout shows the wrong content for the current page.

Before applying a result or redirecting, verify that its location is still current.

Also at `src/BootstrapBlazor/Components/Layout/Layout.razor.cs:739-758`.

{
InvokeAsync(async () =>
var auth = await OnAuthorizing(e.Location);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical · Failed checks retain access

When the authorization callback hangs or throws after the layout was previously authorized, _authenticated is not cleared before OnAuthorizing is awaited, and the dispatched task has no failure handling. If the callback stalls or throws during a dependency failure, the previous authorized state remains active and the layout continues rendering protected content.

Fail closed while authorization is pending or fails, and handle callback exceptions and timeouts explicitly.

Prompt for AI agents
In `src/BootstrapBlazor/Components/Layout/Layout.razor.cs` at line 739:

**Failed checks retain access**

When the authorization callback hangs or throws after the layout was previously authorized, `_authenticated` is not cleared before `OnAuthorizing` is awaited, and the dispatched task has no failure handling. If the callback stalls or throws during a dependency failure, the previous authorized state remains active and the layout continues rendering protected content.

Fail closed while authorization is pending or fails, and handle callback exceptions and timeouts explicitly.

if (auth)
{
var auth = await OnAuthorizing(e.Location);
if (!auth && IsAutoNavigateWhenNotAuthorize)
// 当前地址已授权时恢复 UI 状态
if (!_authenticated)
{
Comment on lines +739 to 744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical · Protected routes render as authorized

When onAuthorizing returns true for a route whose framework authorization check denies the user, Navigation_LocationChanged sets _authenticated from OnAuthorizing alone, bypassing the route handler's IsAuthorizedAsync result, so the layout renders Main for a route the user cannot access.

Combine the callback result with the destination route's framework authorization result before setting _authenticated.

Also at src/BootstrapBlazor/Components/Layout/Layout.razor.cs:745.

Prompt for AI agents
In `src/BootstrapBlazor/Components/Layout/Layout.razor.cs` at lines 739-744:

**Protected routes render as authorized**

When onAuthorizing returns true for a route whose framework authorization check denies the user, `Navigation_LocationChanged` sets `_authenticated` from `OnAuthorizing` alone, bypassing the route handler's `IsAuthorizedAsync` result, so the layout renders `Main` for a route the user cannot access.

Combine the callback result with the destination route's framework authorization result before setting `_authenticated`.

Also at `src/BootstrapBlazor/Components/Layout/Layout.razor.cs:745`.

Navigation.NavigateTo(NotAuthorizeUrl, true);
_authenticated = true;
StateHasChanged();
}
});
}
}
else if (IsAutoNavigateWhenNotAuthorize)
{
Navigation.NavigateTo(NotAuthorizeUrl, true);
}
else if (_authenticated)
{
// 未开启自动跳转时更新 UI 状态显示 NotAuthorized 模板
_authenticated = false;
StateHasChanged();
}
});
}

/// <summary>
Expand Down
123 changes: 123 additions & 0 deletions test/UnitTest/Components/LayoutTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,129 @@ public void IsAutoNavigateWhenNotAuthorize_Ok()
cut.WaitForAssertion(() => Assert.Equal("http://localhost/", navMan.Uri));
}

[Fact]
public void IsAutoNavigateWhenNotAuthorize_RenderNotAuthorized()
{
var navMan = Context.Services.GetRequiredService<BunitNavigationManager>();
var cut = Context.Render<Layout>(pb =>
{
pb.Add(a => a.Resource, null);
pb.Add(a => a.NotAuthorizeUrl, "/Test");
pb.Add(a => a.IsAutoNavigateWhenNotAuthorize, false);
pb.Add(a => a.Main, builder => builder.AddContent(0, "Main"));
pb.Add(a => a.NotAuthorized, builder => builder.AddContent(0, "NotAuth"));
pb.Add(a => a.OnAuthorizing, url => Task.FromResult(false));
});

// 初始状态已授权 渲染 Main 内容
Assert.Contains("Main", cut.Markup);
Assert.DoesNotContain("NotAuth", cut.Markup);

// 导航到未授权地址 不跳转 显示 NotAuthorized 模板
navMan.NavigateTo("/unauthorized");
cut.WaitForAssertion(() => Assert.Contains("NotAuth", cut.Markup));
Assert.Equal("http://localhost/unauthorized", navMan.Uri);
Assert.DoesNotContain("Main", cut.Markup);
}

[Fact]
public void IsAutoNavigateWhenNotAuthorize_RestoreAuthorized()
{
var navMan = Context.Services.GetRequiredService<BunitNavigationManager>();
var authenticated = false;
var cut = Context.Render<Layout>(pb =>
{
pb.Add(a => a.Resource, null);
pb.Add(a => a.NotAuthorizeUrl, "/Test");
pb.Add(a => a.IsAutoNavigateWhenNotAuthorize, false);
pb.Add(a => a.Main, builder => builder.AddContent(0, "Main"));
pb.Add(a => a.NotAuthorized, builder => builder.AddContent(0, "NotAuth"));
pb.Add(a => a.OnAuthorizing, url => Task.FromResult(authenticated));
});

// 导航到未授权地址 显示 NotAuthorized 模板
navMan.NavigateTo("/unauthorized");
cut.WaitForAssertion(() => Assert.Contains("NotAuth", cut.Markup));

// 导航到已授权地址 恢复 Layout 内容
authenticated = true;
navMan.NavigateTo("/authorized");
cut.WaitForAssertion(() => Assert.Contains("Main", cut.Markup));
Assert.DoesNotContain("NotAuth", cut.Markup);
Assert.Equal("http://localhost/authorized", navMan.Uri);
}

[Fact]
public void IsAutoNavigateWhenNotAuthorize_DefaultNavigateToLogin()
{
var navMan = Context.Services.GetRequiredService<BunitNavigationManager>();
var cut = Context.Render<Layout>(pb =>
{
pb.Add(a => a.Resource, null);
pb.Add(a => a.NotAuthorizeUrl, "/Test");
pb.Add(a => a.Main, builder => builder.AddContent(0, "Main"));
pb.Add(a => a.NotAuthorized, builder => builder.AddContent(0, "NotAuth"));
pb.Add(a => a.OnAuthorizing, url => Task.FromResult(url == "http://localhost/Test"));
});

// 默认 IsAutoNavigateWhenNotAuthorize = true 跳转到 NotAuthorizeUrl 不显示 NotAuthorized 模板
navMan.NavigateTo("/unauthorized");
cut.WaitForAssertion(() => Assert.Equal("http://localhost/Test", navMan.Uri));
Assert.DoesNotContain("NotAuth", cut.Markup);
Assert.Contains("Main", cut.Markup);
}

[Fact]
public void IsAutoNavigateWhenNotAuthorize_OnAuthorizingNull()
{
var navMan = Context.Services.GetRequiredService<BunitNavigationManager>();
var cut = Context.Render<Layout>(pb =>
{
pb.Add(a => a.Resource, null);
pb.Add(a => a.NotAuthorizeUrl, "/Test");
pb.Add(a => a.IsAutoNavigateWhenNotAuthorize, false);
pb.Add(a => a.Main, builder => builder.AddContent(0, "Main"));
pb.Add(a => a.NotAuthorized, builder => builder.AddContent(0, "NotAuth"));
});

// 未设置 OnAuthorizing 时不订阅 LocationChanged 事件 导航不执行任何授权逻辑
navMan.NavigateTo("/unauthorized");
Assert.Equal("http://localhost/unauthorized", navMan.Uri);
Assert.Contains("Main", cut.Markup);
Assert.DoesNotContain("NotAuth", cut.Markup);
}

[Fact]
public void IsAutoNavigateWhenNotAuthorize_OnAuthorizingSetToNull()
{
var navMan = Context.Services.GetRequiredService<BunitNavigationManager>();
var invoked = 0;
var cut = Context.Render<Layout>(pb =>
{
pb.Add(a => a.Resource, null);
pb.Add(a => a.NotAuthorizeUrl, "/Test");
pb.Add(a => a.IsAutoNavigateWhenNotAuthorize, false);
pb.Add(a => a.Main, builder => builder.AddContent(0, "Main"));
pb.Add(a => a.NotAuthorized, builder => builder.AddContent(0, "NotAuth"));
pb.Add(a => a.OnAuthorizing, url =>
{
invoked++;
return Task.FromResult(false);
});
});

navMan.NavigateTo("/unauthorized");
cut.WaitForAssertion(() => Assert.Contains("NotAuth", cut.Markup));
Assert.Equal(1, invoked);

// 将 OnAuthorizing 置为 null 后续导航直接返回不再执行授权逻辑
cut.Render(pb => pb.Add(a => a.OnAuthorizing, (Func<string, Task<bool>>?)null));
navMan.NavigateTo("/home");
Assert.Equal("http://localhost/home", navMan.Uri);
Assert.Equal(1, invoked);
Assert.Contains("NotAuth", cut.Markup);
}

[Fact]
public void Main_Ok()
{
Expand Down
Loading