Skip to content

Commit

Permalink
Update Identity Components in Blazor project template (#51134)
Browse files Browse the repository at this point in the history
## Description

This PR addresses a large amount of feedback from #50722 which was merged before they could all be addressed to unblock Accessibility Testing effort. The primary impacts are:

#### Runtime changes
- Public API change to make `AddComponentRenderMode`'s `renderMode` param nullable to support disabling interactivity on a per-page basis with the help of `@rendermode="null"` (effectively).
  - **IMPORTANT:** This will need follow up in the Razor Compiler. See dotnet/razor#9343
  - API Proposal issue: #51170
  - This is a e necessary to support the changes to add global interactivity to Identity components @SteveSandersonMS made in #50920 and have now been included in this PR.
- [Add antiforgery token to forms rendered interactively on the server](425bd12)
  - This bug fix is necessary to make the logout button work without throwing antiforgery errors when it is rendered interactively on the server.

#### Template changes

- Fix compilation error due to missing `using` in `Program.cs` when the individual auth option is selected with no interactivity.
- Add support for global (`--all-interactive`) instead of just per-page interactivity to the new Identity components.
- Fix "Apply Migrations" link on the `DatabaseErrorPage` by calling `UseMigrationsEndPoint()` when necessary. 
- Add support for non-root base paths to the new Identity components.
- Improve folder layout by putting most of the additional auth and Identity related files in the same /Account folder.
- Use the new `IEmailSender<ApplicationUser>` API instead of `IEmailSender` for easier customization of emails.
- Remove usage of `IHttpContextAccessor` from the template because that is generally regarded as bad practice due to the unnecessary reliance on `AsyncLocal`.
- Remove underscore (`_`) from private field names.
- Reduce usage of `null!` and `default!`.
- Use normal `<button>` for logout link in nav bar rather than `<a onclick="document.getElementById('logout-form').submit();">`, and remove separate `LogoutForm.razor`.

## Customer Impact

This fixes several bugs in the Blazor project template when choosing the individual auth option and makes several runtime fixes that will be beneficial to any global interactive Blazor application that needs to include some components that must always be statically rendered.

## Regression?

- [ ] Yes
- [x] No

## Risk

- [ ] High
- [ ] Medium
- [x] Low

Obviously, we would rather not make such a large change after RC2. Particularly when it's a change that touches public API. Fortunately, the runtime changes are very small, and only to parts of the runtime that were last updated in RC2 (see #50181 and #50946).

The vast majority of the changes in the PR only affect the Blazor project template when the non-default individual auth option is selected. This was merged very late in RC2 (#50722) with the expectation that we would make major changes prior to GA.

## Verification

- [x] Manual (required)
- [x] Automated

## Packaging changes reviewed?

- [ ] Yes
- [ ] No
- [x] N/A
  • Loading branch information
halter73 authored Oct 11, 2023
1 parent f0236a9 commit 4a957dd
Show file tree
Hide file tree
Showing 91 changed files with 2,274 additions and 1,920 deletions.
2 changes: 1 addition & 1 deletion src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relative
Microsoft.AspNetCore.Components.Rendering.ComponentState.LogicalParentComponentState.get -> Microsoft.AspNetCore.Components.Rendering.ComponentState?
*REMOVED*Microsoft.AspNetCore.Components.RouteData.RouteData(System.Type! pageType, System.Collections.Generic.IReadOnlyDictionary<string!, object!>! routeValues) -> void
*REMOVED*Microsoft.AspNetCore.Components.RouteData.RouteValues.get -> System.Collections.Generic.IReadOnlyDictionary<string!, object!>!
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddComponentRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddComponentRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode? renderMode) -> void
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddNamedEvent(string! eventType, string! assignedName) -> void
Microsoft.AspNetCore.Components.RenderTree.ComponentFrameFlags
Microsoft.AspNetCore.Components.RenderTree.ComponentFrameFlags.HasCallerSpecifiedRenderMode = 1 -> Microsoft.AspNetCore.Components.RenderTree.ComponentFrameFlags
Expand Down
7 changes: 5 additions & 2 deletions src/Components/Components/src/Rendering/RenderTreeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -627,9 +627,12 @@ public void AddComponentReferenceCapture(int sequence, Action<object> componentR
/// Adds a frame indicating the render mode on the enclosing component frame.
/// </summary>
/// <param name="renderMode">The <see cref="IComponentRenderMode"/>.</param>
public void AddComponentRenderMode(IComponentRenderMode renderMode)
public void AddComponentRenderMode(IComponentRenderMode? renderMode)
{
ArgumentNullException.ThrowIfNull(renderMode);
if (renderMode is null)
{
return;
}

// Note that a ComponentRenderMode frame is technically a child of the Component frame to which it applies,
// hence the terminology of "adding" it rather than "setting" it. For performance reasons, the diffing system
Expand Down
22 changes: 15 additions & 7 deletions src/Components/Components/test/Rendering/RenderTreeBuilderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2141,18 +2141,26 @@ public void CannotAddComponentRenderModeToElement()
}

[Fact]
public void CannotAddNullComponentRenderMode()
public void CanAddNullComponentRenderMode()
{
// Arrange
var builder = new RenderTreeBuilder();

// Act
builder.OpenComponent<TestComponent>(0);
builder.AddComponentParameter(1, "param", 123);
builder.AddComponentRenderMode(null);
builder.CloseComponent();

// Act/Assert
var ex = Assert.Throws<ArgumentNullException>(() =>
{
builder.AddComponentRenderMode(null);
});
Assert.Equal("renderMode", ex.ParamName);
// Assert
Assert.Collection(
builder.GetFrames().AsEnumerable(),
frame =>
{
AssertFrame.Component<TestComponent>(frame, 2, 0);
Assert.False(frame.ComponentFrameFlags.HasFlag(ComponentFrameFlags.HasCallerSpecifiedRenderMode));
},
frame => AssertFrame.Attribute(frame, "param", 123, 1));
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ internal void SetRequestContext(HttpContext context)
{
if (_context == null)
{
return null;
// We're in an interactive context. Use the token persisted during static rendering.
return base.GetAntiforgeryToken();
}

// We already have a callback setup to generate the token when the response starts if needed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public DefaultAntiforgeryStateProvider(PersistentComponentState state)
{
state.PersistAsJson(PersistenceKey, GetAntiforgeryToken());
return Task.CompletedTask;
}, RenderMode.InteractiveWebAssembly);
}, RenderMode.InteractiveAuto);

state.TryTakeFromJson(PersistenceKey, out _currentToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,19 @@ public void CanUseAntiforgeryTokenInWasm()
DispatchToFormCore(dispatchToForm);
}

[Fact]
public void CanUseAntiforgeryTokenWithServerInteractivity()
{
var dispatchToForm = new DispatchToForm(this)
{
Url = "forms/antiforgery-server-interactive",
FormCssSelector = "form",
InputFieldId = "value",
SuppressEnhancedNavigation = true,
};
DispatchToFormCore(dispatchToForm);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using Components.TestServer.RazorComponents;
using Components.TestServer.RazorComponents.Pages.Forms;
using Components.TestServer.Services;
using Microsoft.AspNetCore.Components.WebAssembly.Server;
using Microsoft.AspNetCore.Mvc;

namespace TestServer;
Expand Down Expand Up @@ -155,9 +154,18 @@ private static void MapEnhancedNavigationEndpoints(IEndpointRouteBuilder endpoin
await response.WriteAsync("<html><body><h1>This is a non-Blazor endpoint</h1><p>That's all</p></body></html>");
});

endpoints.MapPost("api/antiforgery-form", ([FromForm] string value) =>
endpoints.MapPost("api/antiforgery-form", (
[FromForm] string value,
[FromForm(Name = "__RequestVerificationToken")] string? inFormCsrfToken,
[FromHeader(Name = "RequestVerificationToken")] string? inHeaderCsrfToken) =>
{
return Results.Ok(value);
// We shouldn't get this far without a valid CSRF token, but we'll double check it's there.
if (string.IsNullOrEmpty(inFormCsrfToken) && string.IsNullOrEmpty(inHeaderCsrfToken))
{
throw new InvalidOperationException("Invalid POST to api/antiforgery-form!");
}

return TypedResults.Text($"<p id='pass'>Hello {value}!</p>", "text/html");
});

endpoints.Map("/forms/endpoint-that-never-finishes-rendering", (HttpResponse response, CancellationToken token) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@page "/forms/antiforgery-server-interactive"

@using Microsoft.AspNetCore.Components.Forms

@rendermode RenderMode.InteractiveServer

<h3>FormRenderedWithServerInteractivityCanUseAntiforgeryToken</h3>

<form action="api/antiforgery-form" method="post">
<AntiforgeryToken />
<input type="text" id="value" name="value" />
@if (HttpContext is null)
{
<input id="send" type="submit" value="Send" />
}
</form>

@code {
[CascadingParameter]
HttpContext? HttpContext { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
{
@if (_succeeded)
{
<p id="pass">Posting the value succeded.</p>
<p id="pass">Posting the value succeeded.</p>
}
else
{
Expand All @@ -42,7 +42,7 @@ else
if (OperatingSystem.IsBrowser())
{
var antiforgery = AntiforgeryState.GetAntiforgeryToken();
_token = antiforgery.Value;
_token = antiforgery.Value;
}
}

Expand Down
19 changes: 19 additions & 0 deletions src/ProjectTemplates/README-BASELINES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generating template-baselines.json

For small project template changes, you may be able to edit the `template-baselines.json` file manually. This is a good way to ensure you have correct expectations about the effects of your changes.

For larger changes such as adding entirely new templates, it may be impractical to type out the changes to `template-baselines.json` manually. In those cases you can follow a procedure like the following.

1. Ensure you've configured the necessary environment variables:
- `set PATH=c:\git\dotnet\aspnetcore\.dotnet\;%PATH%` (update path as needed)
- `set DOTNET_ROOT=c:\git\dotnet\aspnetcore\.dotnet` (update path as needed)
2. Get to a position where you can execute the modified template(s) locally, i.e.:
- Use `dotnet pack ProjectTemplatesNoDeps.slnf` (possibly with `--no-restore --no-dependencies`) to regenerate `Microsoft.DotNet.Web.ProjectTemplates.*.nupkg`
- Run one of the `scripts/*.ps1` scripts to install your template pack and execute your chosen template. For example, run `powershell .\scripts\Run-BlazorWeb-Locally.ps1`
- Once that has run, you should see your updated template listed when you execute `dotnet new list` or `dotnet new YourTemplateName --help`. At the point you can run `dotnet new YourTemplateName -o SomePath` directly if you want. However each time you edit template sources further, you will need to run `dotnet new uninstall Microsoft.DotNet.Web.ProjectTemplates.8.0` and then go back to the start of this whole step.
- Tip: the following command combines the above steps, to go directly from editing template sources to an updated local project output: `dotnet pack ProjectTemplatesNoDeps.slnf --no-restore --no-dependencies && dotnet new uninstall Microsoft.DotNet.Web.ProjectTemplates.8.0 && rm -rf scripts\MyBlazorApp && powershell .\scripts\Run-BlazorWeb-Locally.ps1`
3. After generating a particular project's output, the following can be run in a Bash prompt (e.g., using WSL):
- `cd src/ProjectTemplates/scripts`
- `export PROJECT_NAME=MyBlazorApp` (update as necessary - note this is the name of the directly under `scripts` containing your project output)
- `find $PROJECT_NAME -type f -not -path "*/obj/*" -not -path "*/bin/*" -not -path "*/.publish/*" | sed -e "s/^$PROJECT_NAME\///" | sed -e "s/$PROJECT_NAME/{ProjectName}/g" | sed 's/.*/ "&",/' | sort -f`
- This will emit the JSON-formatted lines you can manually insert into the relevant place inside `template-baselines.json`
2 changes: 1 addition & 1 deletion src/ProjectTemplates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Otherwise, you'll get a test error "Certificate error: Navigation blocked".

Then, use one of:

1. Run `src\ProjectTemplates\build.cmd -test -NoRestore -NoBuild -NoBuilddeps -configuration Release` (or equivalent src\ProjectTemplates\build.sh` command) to run all template tests.
1. Run `src\ProjectTemplates\build.cmd -test -NoRestore -NoBuild -NoBuildDeps -configuration Release` (or equivalent src\ProjectTemplates\build.sh` command) to run all template tests.
1. To test specific templates, use the `Run-[Template]-Locally.ps1` scripts in the script folder.
- These scripts do `dotnet new -i` with your packages, but also apply a series of fixes and tweaks to the created template which keep the fact that you don't have a production `Microsoft.AspNetCore.App` from interfering.
1. Run templates manually with `custom-hive` and `disable-sdk-templates` to install to a custom location and turn off the built-in templates e.g.
Expand Down
5 changes: 4 additions & 1 deletion src/ProjectTemplates/Shared/ArgConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ internal static class ArgConstants
public const string CalledApiScopes = "--called-api-scopes";
public const string CalledApiScopesUserReadWrite = $"{CalledApiScopes} user.readwrite";
public const string NoOpenApi = "--no-openapi";
public const string Auth = "-au";
public const string ClientId = "--client-id";
public const string Domain = "--domain";
public const string DefaultScope = "--default-scope";
Expand All @@ -26,5 +25,9 @@ internal static class ArgConstants
public const string NoHttps = "--no-https";
public const string PublishNativeAot = "--aot";
public const string NoInteractivity = "--interactivity none";
public const string WebAssemblyInteractivity = "--interactivity WebAssembly";
public const string AutoInteractivity = "--interactivity Auto";
public const string GlobalInteractivity = "--all-interactive";
public const string Empty = "--empty";
public const string IndividualAuth = "--auth Individual";
}
17 changes: 12 additions & 5 deletions src/ProjectTemplates/Shared/AspNetProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,6 @@ public AspNetProcess(
_output = output;
_httpClient = new HttpClient(new HttpClientHandler()
{
AllowAutoRedirect = true,
UseCookies = true,
CookieContainer = new CookieContainer(),
ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) => (certificate.Subject != "CN=localhost" && errors == SslPolicyErrors.None) || certificate?.Thumbprint == _developmentCertificate.CertificateThumbprint,
})
{
Expand Down Expand Up @@ -124,6 +121,14 @@ public async Task AssertPagesOk(IEnumerable<Page> pages)
}
}

public async Task AssertPagesNotFound(IEnumerable<string> urls)
{
foreach (var url in urls)
{
await AssertNotFound(url);
}
}

public async Task ContainsLinks(Page page)
{
var response = await RetryHelper.RetryRequest(async () =>
Expand Down Expand Up @@ -290,8 +295,10 @@ public override string ToString()
}
}

public class Page
public class Page(string url)
{
public string Url { get; set; }
public Page() : this(null) { }

public string Url { get; set; } = url;
public IEnumerable<string> Links { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>DotNetEfFullPath</_Parameter1>
<_Parameter2>$([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)'))dotnet-ef/$(DotnetEfVersion)/tools/net6.0/any/dotnet-ef.dll</_Parameter2>
<_Parameter2>$([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)'))dotnet-ef/$(DotnetEfVersion)/tools/net8.0/any/dotnet-ef.dll</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>TestPackageRestorePath</_Parameter1>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<!--#if (IndividualLocalAuth && !UseLocalDB) -->
<ItemGroup>
<None Update="app.db" CopyToOutputDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
<None Update="Data\app.db" CopyToOutputDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
</ItemGroup>

<!--#endif -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@
{
"condition": "(!UseWebAssembly)",
"exclude": [
"BlazorWeb-CSharp/wwwroot/appsettings.Development.json",
"BlazorWeb-CSharp/wwwroot/appsettings.json",
"BlazorWeb-CSharp.Client/**",
"*.sln"
],
Expand All @@ -67,7 +65,8 @@
"condition": "(UseWebAssembly && InteractiveAtRoot)",
"rename": {
"BlazorWeb-CSharp/Components/Layout/": "./BlazorWeb-CSharp.Client/Layout/",
"BlazorWeb-CSharp/Components/Pages/": "./BlazorWeb-CSharp.Client/Pages/",
"BlazorWeb-CSharp/Components/Pages/Home.razor": "./BlazorWeb-CSharp.Client/Pages/Home.razor",
"BlazorWeb-CSharp/Components/Pages/Weather.razor": "./BlazorWeb-CSharp.Client/Pages/Weather.razor",
"BlazorWeb-CSharp/Components/Routes.razor": "./BlazorWeb-CSharp.Client/Routes.razor"
}
},
Expand Down Expand Up @@ -101,6 +100,7 @@
{
"condition": "(!SampleContent)",
"exclude": [
"BlazorWeb-CSharp/Components/Pages/Auth.*",
"BlazorWeb-CSharp/Components/Pages/Counter.*",
"BlazorWeb-CSharp/Components/Pages/Weather.*",
"BlazorWeb-CSharp/Components/Layout/NavMenu.*",
Expand All @@ -113,12 +113,8 @@
{
"condition": "(!IndividualLocalAuth)",
"exclude": [
"BlazorWeb-CSharp/Components/Identity/**",
"BlazorWeb-CSharp/Components/Layout/ManageLayout.razor",
"BlazorWeb-CSharp/Components/Layout/ManageNavMenu.razor",
"BlazorWeb-CSharp/Components/Pages/Account/**",
"BlazorWeb-CSharp/Components/Account/**",
"BlazorWeb-CSharp/Data/**",
"BlazorWeb-CSharp/Identity/**",
"BlazorWeb-CSharp.Client/PersistentAuthenticationStateProvider.cs",
"BlazorWeb-CSharp.Client/UserInfo.cs",
"BlazorWeb-CSharp.Client/Pages/Auth.razor"
Expand All @@ -127,7 +123,7 @@
{
"condition": "(!(IndividualLocalAuth && !UseLocalDB))",
"exclude": [
"BlazorWeb-CSharp/app.db"
"BlazorWeb-CSharp/Data/app.db"
]
},
{
Expand All @@ -139,19 +135,19 @@
{
"condition": "(!(IndividualLocalAuth && UseServer && UseWebAssembly))",
"exclude": [
"BlazorWeb-CSharp/Identity/PersistingRevalidatingAuthenticationStateProvider.cs"
"BlazorWeb-CSharp/Components/Account/PersistingRevalidatingAuthenticationStateProvider.cs"
]
},
{
"condition": "(!(IndividualLocalAuth && UseServer && !UseWebAssembly))",
"exclude": [
"BlazorWeb-CSharp/Identity/IdentityRevalidatingAuthenticationStateProvider.cs"
"BlazorWeb-CSharp/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs"
]
},
{
"condition": "(!(IndividualLocalAuth && !UseServer && UseWebAssembly))",
"exclude": [
"BlazorWeb-CSharp/Identity/PersistingServerAuthenticationStateProvider.cs"
"BlazorWeb-CSharp/Components/Account/PersistingServerAuthenticationStateProvider.cs"
]
},
{
Expand Down Expand Up @@ -189,6 +185,12 @@
"exclude": [
"BlazorWeb-CSharp/Data/SqlServer/**"
]
},
{
"condition": "(IndividualLocalAuth && UseWebAssembly)",
"rename": {
"BlazorWeb-CSharp/Components/Account/Shared/RedirectToLogin.razor": "BlazorWeb-CSharp.Client/RedirectToLogin.razor"
}
}
]
}
Expand Down Expand Up @@ -250,7 +252,7 @@
"sourceVariableName": "kestrelHttpPort",
"fallbackVariableName": "kestrelHttpPortGenerated"
},
"replaces": "5000"
"replaces": "5500"
},
"kestrelHttpsPort": {
"type": "parameter",
Expand All @@ -272,7 +274,7 @@
"sourceVariableName": "kestrelHttpsPort",
"fallbackVariableName": "kestrelHttpsPortGenerated"
},
"replaces": "5001"
"replaces": "5501"
},
"iisHttpPort": {
"type": "parameter",
Expand Down Expand Up @@ -349,7 +351,7 @@
"defaultValue": "InteractivePerPage",
"displayName": "_Interactivity location",
"description": "Chooses which components will have interactive rendering enabled",
"isEnabled": "(InteractivityPlatform != \"None\" && auth == \"None\")",
"isEnabled": "(InteractivityPlatform != \"None\")",
"choices": [
{
"choice": "InteractivePerPage",
Expand Down Expand Up @@ -413,7 +415,7 @@
"AllInteractive": {
"type": "parameter",
"datatype": "bool",
"isEnabled": "(InteractivityPlatform != \"None\" && auth == \"None\")",
"isEnabled": "(InteractivityPlatform != \"None\")",
"defaultValue": "false",
"displayName": "_Enable interactive rendering globally throughout the site",
"description": "Configures whether to make every page interactive by applying an interactive render mode at the top level. If false, pages will use static server rendering by default, and can be marked interactive on a per-page or per-component basis."
Expand Down
Loading

0 comments on commit 4a957dd

Please sign in to comment.