From ca048f0844e6239a26c96f98c2a83d729afa7c71 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 17 Sep 2026 18:50:51 -0700 Subject: [PATCH 1/9] Fully configure MVC and Razor Pages Identity Add missing Identity services, authentication middleware, Razor Pages endpoints, login navigation, and an initial EF Core migration. Add end-to-end coverage for clean MVC and Razor Pages projects, runtime endpoints, and repeated scaffolding. Fixes #3831 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c90ed2f5-6b56-45f7-8157-d3fa618a7ffc --- .../AspNet/AspNetCommandService.cs | 6 +- .../IdentityScaffolderBuilderExtensions.cs | 49 ++++ .../ScaffoldSteps/AddIdentityMigrationStep.cs | 153 ++++++++++++ .../ConfigureIdentityNavigationStep.cs | 111 +++++++++ .../identityChanges.json | 34 ++- .../identityChanges.json | 34 ++- .../identityChanges.json | Bin 3108 -> 2788 bytes ...dentityScaffolderBuilderExtensionsTests.cs | 26 ++ .../Identity/IdentityEndToEndNet10Tests.cs | 227 ++++++++++++++++++ .../AddIdentityMigrationStepTests.cs | 37 +++ .../ConfigureIdentityNavigationStepTests.cs | 80 ++++++ 11 files changed, 754 insertions(+), 3 deletions(-) create mode 100644 src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddIdentityMigrationStep.cs create mode 100644 src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs create mode 100644 test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs create mode 100644 test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/AddIdentityMigrationStepTests.cs create mode 100644 test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/AspNetCommandService.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/AspNetCommandService.cs index b108c43644..074ff3f61f 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/AspNetCommandService.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/AspNetCommandService.cs @@ -26,7 +26,9 @@ public Type[] GetScaffoldSteps() typeof(AddAspNetConnectionStringStep), typeof(AddDbSetToExistingContextStep), typeof(AddFileStep), + typeof(AddIdentityMigrationStep), typeof(AreaScaffolderStep), + typeof(ConfigureIdentityNavigationStep), typeof(DetectBlazorWasmStep), typeof(DotnetNewScaffolderStep), typeof(EmptyControllerScaffolderStep), @@ -342,7 +344,9 @@ public void AddScaffolderCommands() .WithIdentityDbContextStep() .WithAspNetConnectionStringStep() .WithIdentityTextTemplatingStep() - .WithIdentityCodeChangeStep(); + .WithIdentityCodeChangeStep() + .WithIdentityNavigationStep() + .WithIdentityMigrationStep(); _builder.AddScaffolder(ScaffolderCatagory.AspNet, AspnetStrings.EntraId.Name) .WithDisplayName(AspnetStrings.EntraId.DisplayName) diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs index b445a84b46..2285c2852d 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs @@ -32,6 +32,7 @@ public static IScaffoldBuilder WithIdentityAddPackagesStep(this IScaffoldBuilder List packages = [ PackageConstants.AspNetCorePackages.AspNetCoreIdentityEfPackage, PackageConstants.AspNetCorePackages.AspNetCoreIdentityUiPackage, + PackageConstants.AspNetCorePackages.AspNetCoreDiagnosticsEfCorePackage, PackageConstants.EfConstants.EfCoreToolsPackage, PackageConstants.EfConstants.EfCoreDesignPackage ]; @@ -155,4 +156,52 @@ codeModifierProperties is not null && return builder; } + + /// + /// Adds a step to configure Identity navigation in the host application's layout. + /// + /// The scaffold builder. + /// The updated scaffold builder. + public static IScaffoldBuilder WithIdentityNavigationStep(this IScaffoldBuilder builder) + { + return builder.WithStep(config => + { + var step = config.Step; + if (config.Context.Properties.TryGetValue(nameof(IdentityModel), out var identityModelObj) && + identityModelObj is IdentityModel identityModel) + { + step.ProjectPath = identityModel.ProjectInfo.ProjectPath ?? string.Empty; + step.IsRazorPages = identityModel.IsRazorPages; + step.UserClassName = identityModel.UserClassName; + step.UserClassNamespace = identityModel.UserClassNamespace; + } + else + { + step.SkipStep = true; + } + }); + } + + /// + /// Adds a step to generate an initial EF Core migration for Identity. + /// + /// The scaffold builder. + /// The updated scaffold builder. + public static IScaffoldBuilder WithIdentityMigrationStep(this IScaffoldBuilder builder) + { + return builder.WithStep(config => + { + var step = config.Step; + if (config.Context.Properties.TryGetValue(nameof(IdentityModel), out var identityModelObj) && + identityModelObj is IdentityModel identityModel) + { + step.ProjectPath = identityModel.ProjectInfo.ProjectPath ?? string.Empty; + step.DbContextName = identityModel.DbContextInfo.DbContextClassName ?? string.Empty; + } + else + { + step.SkipStep = true; + } + }); + } } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddIdentityMigrationStep.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddIdentityMigrationStep.cs new file mode 100644 index 0000000000..816dff9fcd --- /dev/null +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddIdentityMigrationStep.cs @@ -0,0 +1,153 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; +using Microsoft.DotNet.Scaffolding.Core.Scaffolders; +using Microsoft.DotNet.Scaffolding.Core.Steps; +using Microsoft.DotNet.Scaffolding.Internal.CliHelpers; +using Microsoft.DotNet.Scaffolding.Internal.Services; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Tools.Scaffold.AspNet.ScaffoldSteps; + +/// +/// Generates an initial EF Core migration for a newly configured Identity context. +/// +internal class AddIdentityMigrationStep( + ILogger logger, + IFileSystem fileSystem) : ScaffoldStep +{ + public required string ProjectPath { get; set; } + public required string DbContextName { get; set; } + + public override Task ExecuteAsync(ScaffolderContext context, CancellationToken cancellationToken = default) + { + var projectDirectory = Path.GetDirectoryName(ProjectPath); + if (string.IsNullOrEmpty(projectDirectory) || string.IsNullOrEmpty(DbContextName)) + { + logger.LogError("Unable to determine the project or DbContext while generating the Identity migration."); + return Task.FromResult(false); + } + + if (HasMigration(projectDirectory)) + { + return Task.FromResult(true); + } + + var assetsPath = Path.Combine(projectDirectory, "obj", "project.assets.json"); + if (!fileSystem.FileExists(assetsPath)) + { + logger.LogError($"Unable to generate the Identity migration because '{assetsPath}' does not exist."); + return Task.FromResult(false); + } + + var efVersion = GetEfDesignPackageVersion(fileSystem.ReadAllText(assetsPath)); + if (string.IsNullOrEmpty(efVersion)) + { + logger.LogError("Unable to determine the Microsoft.EntityFrameworkCore.Design package version."); + return Task.FromResult(false); + } + + var toolDirectory = Path.Combine(fileSystem.GetTempPath(), "dotnet-scaffold", Guid.NewGuid().ToString("N")); + try + { + fileSystem.CreateDirectoryIfNotExists(toolDirectory); + if (!InstallEfTool(toolDirectory, projectDirectory, efVersion)) + { + return Task.FromResult(false); + } + + return Task.FromResult(AddMigration(toolDirectory, projectDirectory)); + } + finally + { + try + { + Directory.Delete(toolDirectory, recursive: true); + } + catch (Exception ex) + { + logger.LogWarning($"Unable to remove temporary EF Core tooling directory '{toolDirectory}': {ex.Message}"); + } + } + } + + internal static string? GetEfDesignPackageVersion(string assetsContent) + { + using var document = JsonDocument.Parse(assetsContent); + if (!document.RootElement.TryGetProperty("libraries", out var libraries)) + { + return null; + } + + const string packagePrefix = "Microsoft.EntityFrameworkCore.Design/"; + foreach (var library in libraries.EnumerateObject()) + { + if (library.Name.StartsWith(packagePrefix, StringComparison.OrdinalIgnoreCase)) + { + return library.Name[packagePrefix.Length..]; + } + } + + return null; + } + + private bool HasMigration(string projectDirectory) + { + return fileSystem.EnumerateFiles(projectDirectory, "*ModelSnapshot.cs", SearchOption.AllDirectories) + .Any(path => fileSystem.ReadAllText(path).Contains(DbContextName, StringComparison.Ordinal)); + } + + private bool InstallEfTool(string toolDirectory, string projectDirectory, string version) + { + logger.LogInformation("Installing temporary EF Core tooling..."); + var runner = DotnetCliRunner.CreateDotNet("tool", + [ + "install", + "dotnet-ef", + "--tool-path", + toolDirectory, + "--version", + version + ]); + runner._psi.WorkingDirectory = projectDirectory; + var exitCode = runner.ExecuteAndCaptureOutput(out var stdOut, out var stdErr); + if (exitCode == 0) + { + return true; + } + + logger.LogError($"Unable to install dotnet-ef {version}.{Environment.NewLine}{stdOut}{Environment.NewLine}{stdErr}"); + return false; + } + + private bool AddMigration(string toolDirectory, string projectDirectory) + { + logger.LogInformation("Generating initial Identity migration..."); + var executableName = OperatingSystem.IsWindows() ? "dotnet-ef.exe" : "dotnet-ef"; + var runner = DotnetCliRunner.Create(Path.Combine(toolDirectory, executableName), + [ + "migrations", + "add", + "CreateIdentitySchema", + "--project", + ProjectPath, + "--startup-project", + ProjectPath, + "--context", + DbContextName, + "--output-dir", + Path.Combine("Data", "Migrations"), + "--no-color" + ]); + runner._psi.WorkingDirectory = projectDirectory; + var exitCode = runner.ExecuteAndCaptureOutput(out var stdOut, out var stdErr); + if (exitCode == 0) + { + logger.LogInformation("Done"); + return true; + } + + logger.LogError($"Unable to generate the initial Identity migration.{Environment.NewLine}{stdOut}{Environment.NewLine}{stdErr}"); + return false; + } +} diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs new file mode 100644 index 0000000000..fc823cbf97 --- /dev/null +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.DotNet.Scaffolding.Core.Scaffolders; +using Microsoft.DotNet.Scaffolding.Core.Steps; +using Microsoft.DotNet.Scaffolding.Internal.Services; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Tools.Scaffold.AspNet.ScaffoldSteps; + +/// +/// Adds the Identity login partial and references it from the host application's layout. +/// +internal class ConfigureIdentityNavigationStep( + ILogger logger, + IFileSystem fileSystem) : ScaffoldStep +{ + public required string ProjectPath { get; set; } + public required string UserClassName { get; set; } + public required string UserClassNamespace { get; set; } + public bool IsRazorPages { get; set; } + + public override Task ExecuteAsync(ScaffolderContext context, CancellationToken cancellationToken = default) + { + var projectDirectory = Path.GetDirectoryName(ProjectPath); + if (string.IsNullOrEmpty(projectDirectory)) + { + logger.LogError("Unable to determine the project directory while configuring Identity navigation."); + return Task.FromResult(false); + } + + var sharedDirectory = Path.Combine(projectDirectory, IsRazorPages ? "Pages" : "Views", "Shared"); + var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); + if (!fileSystem.FileExists(layoutPath)) + { + logger.LogWarning($"Identity navigation was not added because '{layoutPath}' does not exist."); + return Task.FromResult(true); + } + + fileSystem.CreateDirectoryIfNotExists(sharedDirectory); + var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); + if (!fileSystem.FileExists(loginPartialPath)) + { + fileSystem.WriteAllText(loginPartialPath, GetLoginPartialContent()); + } + + var layoutContent = fileSystem.ReadAllText(layoutPath); + if (layoutContent.Contains("_LoginPartial", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(true); + } + + var closingListIndex = layoutContent.IndexOf("", StringComparison.OrdinalIgnoreCase); + if (closingListIndex < 0) + { + logger.LogWarning($"Identity navigation was not added to '{layoutPath}' because no navigation list was found."); + return Task.FromResult(true); + } + + var lineStartIndex = layoutContent.LastIndexOf('\n', closingListIndex); + lineStartIndex = lineStartIndex < 0 ? 0 : lineStartIndex + 1; + var indentation = layoutContent[lineStartIndex..closingListIndex]; + if (indentation.Any(character => !char.IsWhiteSpace(character))) + { + indentation = string.Empty; + } + + var newline = layoutContent.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var insertionIndex = closingListIndex + "".Length; + layoutContent = layoutContent.Insert(insertionIndex, $"{newline}{indentation}"); + fileSystem.WriteAllText(layoutPath, layoutContent); + + return Task.FromResult(true); + } + + private string GetLoginPartialContent() + { + var returnUrl = IsRazorPages + ? "@Url.Page(\"/Index\", new { area = \"\" })" + : "@Url.Action(\"Index\", \"Home\", new { area = \"\" })"; + + return $$""" +@using Microsoft.AspNetCore.Identity +@using {{UserClassNamespace}} +@inject SignInManager<{{UserClassName}}> SignInManager +@inject UserManager<{{UserClassName}}> UserManager + + +"""; + } +} diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json index d85d6c05b7..9797eab9d4 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json @@ -29,7 +29,39 @@ { "InsertAfter": "builder.Services.AddDbContext", "CheckBlock": "builder.Services.AddDefaultIdentity", - "Block": "builder.Services.AddDefaultIdentity<$(UserClassName)>(options => options.SignIn.RequireConfirmedAccount = true).AddEntityFrameworkStores<$(DbContextName)>()\"", + "Block": "builder.Services.AddDefaultIdentity<$(UserClassName)>(options => options.SignIn.RequireConfirmedAccount = true).AddEntityFrameworkStores<$(DbContextName)>()", + "LeadingTrivia": { + "Newline": true + } + }, + { + "InsertAfter": "builder.Services.AddDbContext", + "CheckBlock": "builder.Services.AddDatabaseDeveloperPageExceptionFilter", + "Block": "builder.Services.AddDatabaseDeveloperPageExceptionFilter()", + "LeadingTrivia": { + "Newline": true + } + }, + { + "CheckBlock": "builder.Services.AddRazorPages", + "Block": "builder.Services.AddRazorPages()", + "InsertBefore": [ "builder.Build()", "WebApplication.CreateBuilder.Build()" ], + "LeadingTrivia": { + "Newline": true + } + }, + { + "CheckBlock": "app.UseAuthentication", + "Block": "app.UseAuthentication()", + "InsertBefore": [ "app.UseAuthorization()", "app.MapStaticAssets", "app.MapControllerRoute", "app.MapRazorPages", "app.Run();" ], + "LeadingTrivia": { + "Newline": true + } + }, + { + "CheckBlock": "app.MapRazorPages", + "Block": "app.MapRazorPages()", + "InsertBefore": [ "app.Run();" ], "LeadingTrivia": { "Newline": true } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json index d85d6c05b7..9797eab9d4 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json @@ -29,7 +29,39 @@ { "InsertAfter": "builder.Services.AddDbContext", "CheckBlock": "builder.Services.AddDefaultIdentity", - "Block": "builder.Services.AddDefaultIdentity<$(UserClassName)>(options => options.SignIn.RequireConfirmedAccount = true).AddEntityFrameworkStores<$(DbContextName)>()\"", + "Block": "builder.Services.AddDefaultIdentity<$(UserClassName)>(options => options.SignIn.RequireConfirmedAccount = true).AddEntityFrameworkStores<$(DbContextName)>()", + "LeadingTrivia": { + "Newline": true + } + }, + { + "InsertAfter": "builder.Services.AddDbContext", + "CheckBlock": "builder.Services.AddDatabaseDeveloperPageExceptionFilter", + "Block": "builder.Services.AddDatabaseDeveloperPageExceptionFilter()", + "LeadingTrivia": { + "Newline": true + } + }, + { + "CheckBlock": "builder.Services.AddRazorPages", + "Block": "builder.Services.AddRazorPages()", + "InsertBefore": [ "builder.Build()", "WebApplication.CreateBuilder.Build()" ], + "LeadingTrivia": { + "Newline": true + } + }, + { + "CheckBlock": "app.UseAuthentication", + "Block": "app.UseAuthentication()", + "InsertBefore": [ "app.UseAuthorization()", "app.MapStaticAssets", "app.MapControllerRoute", "app.MapRazorPages", "app.Run();" ], + "LeadingTrivia": { + "Newline": true + } + }, + { + "CheckBlock": "app.MapRazorPages", + "Block": "app.MapRazorPages()", + "InsertBefore": [ "app.Run();" ], "LeadingTrivia": { "Newline": true } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net9.0/CodeModificationConfigs/identityChanges.json b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net9.0/CodeModificationConfigs/identityChanges.json index 8ca60ea8f7695da1952eaa573974b0681bf01136..9797eab9d43c2e5da0f812e5248b0f677d66f4bf 100644 GIT binary patch literal 2788 zcmds3TW{Jh6n^JdSW%@W(v%;tt*rvI71DYUtlGoi!8j*i)wMI*fvy$*eaE>H$SB%F zH;o5KY=6h!`Ob~k9wFWp7ht@P<$c*=)+J7)yE*hM} zdN-VDWmICr$fUPm2TDUYia}a#f7Z~}!OMK_3a!Ih=|_jyK6v6k7|;Vjjd5g5N*M%R z$zfKLxl1iqa)-@jGGQR8=4u~s19qum2dErIR@Xw!sc5a?>OjRXSW>yzQptJiJ`K2d z_eK)w+9bdKm)bV&$x(jzhYlN-AzkikA0!oqr=XNW8px_gfB8( zn7DSTN(syvoLG%P*f&($n93E^gzW&x89CaxKlM)h-5Xsff~gmfiwk0xTCItMH5uV$ zg2#6$6kIQ#8RT(68;u2Ga?<4VH)cR5voE$GhsWLM(N98ZQE#6xYa&_ zPK76YAxAPC!;g$>z@R2vKS3NZhDm6#Se$5q3;9m7npo^srX!61f0U@Db827+D-a3` z{+=$Nu6LX$uK%@b75m%a&>dmY|LelOoxo%IqH;N!Sy@%uVF4Oomz%|4=|kcMBo6Cl zKJ?w3aFD~}CalhpdTiFb-nWIcHKb`8Vv$CfUAq4X#iTrtJDul`Q)~;WG=IqlY9XB4 zxuNOA;u?#L0gKTiUb*c0yjDVh9;?ja|C4&BiCMKc&ZHlF^8P`Q(Z6lFt**aaoTbyv Z+2h51H|Mv=&+6xDkQKk~d)C|ZegQJ%_&NXp literal 3108 zcmdT`+iuf95S?cv{=u@4T1&_ufM_cSX@x`!BA`CdJ~*+PS~YR8Q(A=j>xFY>v%b`J zvWN%?S&7%XJGV1uX7|^_PkAa&#KYsup`?;YA|=KzF!QBa<9fZ5Uu}sKiR3FJjhrtZ zq>x_Z6hBgKLTLxUljck?m z{+(twbHkjBpgq!T53$T5Vc)}A?G7LLCN9K#2}Y0^99u4);%kceYdo29D5}s5;So8* znM7X84c0UDl=A(ov3B|PHKGJ~UqPCPaAYk{Tl#2=8N7gvIaXL3Hmji=!M@?}0(+>@ zu1zpv9$6!9K6O1ggY3##-{=N>jpHVk9vgTbYWP+Uq^2tum4#5r|<9P!%`);7;f=lOl2P7h3V>-hp;FcBgV!a5q6Wh=6AX-n!mR znn?Q&XUlNp>#Q4258qY-u-BE-6Yy`2j>nE1%15y08)8nuB%b^B05e5JTmr#VsFa{oWn*HC?)_ts^%TH7=AmytK$zX46EuTcO1 diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Extensions/IdentityScaffolderBuilderExtensionsTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Extensions/IdentityScaffolderBuilderExtensionsTests.cs index 3cbe298a46..c6b8293817 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Extensions/IdentityScaffolderBuilderExtensionsTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Extensions/IdentityScaffolderBuilderExtensionsTests.cs @@ -58,4 +58,30 @@ public void WithIdentityCodeChangeStep_ReturnsBuilder() Assert.NotNull(result); mockBuilder.Verify(b => b.WithStep(It.IsAny>>()), Times.Once); } + + [Fact] + public void WithIdentityNavigationStep_ReturnsBuilder() + { + Mock mockBuilder = new Mock(); + mockBuilder.Setup(b => b.WithStep(It.IsAny>>())) + .Returns(mockBuilder.Object); + + IScaffoldBuilder result = mockBuilder.Object.WithIdentityNavigationStep(); + + Assert.NotNull(result); + mockBuilder.Verify(b => b.WithStep(It.IsAny>>()), Times.Once); + } + + [Fact] + public void WithIdentityMigrationStep_ReturnsBuilder() + { + Mock mockBuilder = new Mock(); + mockBuilder.Setup(b => b.WithStep(It.IsAny>>())) + .Returns(mockBuilder.Object); + + IScaffoldBuilder result = mockBuilder.Object.WithIdentityMigrationStep(); + + Assert.NotNull(result); + mockBuilder.Verify(b => b.WithStep(It.IsAny>>()), Times.Once); + } } diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs new file mode 100644 index 0000000000..8938d757e0 --- /dev/null +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs @@ -0,0 +1,227 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.DotNet.Tools.Scaffold.Tests.Helpers; +using Xunit; + +namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.Integration.Identity; + +[Trait("Suite", "ScaffoldIntegration")] +[Trait("Family", "identity")] +public class IdentityEndToEndNet10Tests +{ + [Theory] + [InlineData("mvc", "Views")] + [InlineData("webapp", "Pages")] + public async Task ScaffoldIdentity_ConfiguresCleanProject(string templateName, string hostFolder) + { + var projectName = templateName == "mvc" ? "MvcNoAuth" : "RazorNoAuth"; + var testDirectory = Path.Combine(Path.GetTempPath(), nameof(IdentityEndToEndNet10Tests), Guid.NewGuid().ToString("N")); + var projectDirectory = Path.Combine(testDirectory, projectName); + var projectPath = Path.Combine(projectDirectory, $"{projectName}.csproj"); + + Directory.CreateDirectory(testDirectory); + try + { + var createResult = await RunDotNetAsync( + testDirectory, + "new", templateName, + "--name", projectName, + "--output", projectDirectory, + "--framework", "net10.0", + "--auth", "None", + "--no-restore"); + Assert.True(createResult.ExitCode == 0, $"Project creation failed.{Environment.NewLine}{createResult.Output}{Environment.NewLine}{createResult.Error}"); + + var scaffoldResult = await ScaffoldCliHelper.RunScaffoldAsync( + "net10.0", + "identity", + "--project", projectPath, + "--dataContext", "ApplicationDbContext", + "--dbProvider", "sqlite-efcore"); + Assert.True(scaffoldResult.ExitCode == 0, $"Identity scaffolding failed.{Environment.NewLine}{scaffoldResult.Output}{Environment.NewLine}{scaffoldResult.Error}"); + + AssertConfiguredProject(projectDirectory, hostFolder); + + var buildResult = await ScaffoldCliHelper.RunBuildForFrameworkAsync(projectDirectory, "net10.0"); + Assert.True(buildResult.ExitCode == 0, $"Scaffolded project failed to build.{Environment.NewLine}{buildResult.Output}{Environment.NewLine}{buildResult.Error}"); + + var sourceHashes = GetSourceHashes(projectDirectory); + var repeatResult = await ScaffoldCliHelper.RunScaffoldAsync( + "net10.0", + "identity", + "--project", projectPath, + "--dataContext", "ApplicationDbContext", + "--dbProvider", "sqlite-efcore"); + Assert.True(repeatResult.ExitCode == 0, $"Repeated Identity scaffolding failed.{Environment.NewLine}{repeatResult.Output}{Environment.NewLine}{repeatResult.Error}"); + Assert.Equal(sourceHashes, GetSourceHashes(projectDirectory)); + + await AssertIdentityEndpointsAsync(projectPath); + } + finally + { + try + { + Directory.Delete(testDirectory, recursive: true); + } + catch + { + // Best-effort cleanup; preserve any test failure. + } + } + } + + private static void AssertConfiguredProject(string projectDirectory, string hostFolder) + { + var programContent = File.ReadAllText(Path.Combine(projectDirectory, "Program.cs")); + Assert.Contains("AddDatabaseDeveloperPageExceptionFilter", programContent); + Assert.Contains("AddRazorPages", programContent); + Assert.Contains("UseAuthentication", programContent); + Assert.Contains("MapRazorPages", programContent); + + var sharedDirectory = Path.Combine(projectDirectory, hostFolder, "Shared"); + var layoutContent = File.ReadAllText(Path.Combine(sharedDirectory, "_Layout.cshtml")); + var loginPartialContent = File.ReadAllText(Path.Combine(sharedDirectory, "_LoginPartial.cshtml")); + Assert.Contains("", layoutContent); + Assert.Contains("asp-page=\"/Account/Login\"", loginPartialContent); + Assert.Contains("asp-page=\"/Account/Register\"", loginPartialContent); + + var migrationsDirectory = Path.Combine(projectDirectory, "Data", "Migrations"); + Assert.True(Directory.Exists(migrationsDirectory)); + Assert.Contains(Directory.GetFiles(migrationsDirectory), path => path.EndsWith("_CreateIdentitySchema.cs", StringComparison.Ordinal)); + Assert.Contains(Directory.GetFiles(migrationsDirectory), path => path.EndsWith("ApplicationDbContextModelSnapshot.cs", StringComparison.Ordinal)); + Assert.Empty(Directory.GetFiles(projectDirectory, "*.db", SearchOption.AllDirectories)); + } + + private static async Task AssertIdentityEndpointsAsync(string projectPath) + { + var port = GetAvailablePort(); + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = ScaffoldCliHelper.GetDotNetPath(), + WorkingDirectory = Path.GetDirectoryName(projectPath)!, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.StartInfo.ArgumentList.Add("run"); + process.StartInfo.ArgumentList.Add("--no-build"); + process.StartInfo.ArgumentList.Add("--project"); + process.StartInfo.ArgumentList.Add(projectPath); + process.StartInfo.ArgumentList.Add("--urls"); + process.StartInfo.ArgumentList.Add($"http://127.0.0.1:{port}"); + + var output = new StringBuilder(); + process.OutputDataReceived += (_, args) => output.AppendLine(args.Data); + process.ErrorDataReceived += (_, args) => output.AppendLine(args.Data); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var rootContent = await GetWithRetryAsync(client, $"http://127.0.0.1:{port}/", process, output); + Assert.Contains("/Identity/Account/Login", rootContent); + Assert.Contains("/Identity/Account/Register", rootContent); + + await GetWithRetryAsync(client, $"http://127.0.0.1:{port}/Identity/Account/Login", process, output); + await GetWithRetryAsync(client, $"http://127.0.0.1:{port}/Identity/Account/Register", process, output); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + private static async Task GetWithRetryAsync(HttpClient client, string url, Process process, StringBuilder output) + { + for (var attempt = 0; attempt < 30; attempt++) + { + if (process.HasExited) + { + Assert.Fail($"The scaffolded application exited unexpectedly.{Environment.NewLine}{output}"); + } + + try + { + using var response = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadAsStringAsync(); + } + catch (HttpRequestException) when (attempt < 29) + { + await Task.Delay(500); + } + } + + Assert.Fail($"The scaffolded application did not become reachable at '{url}'.{Environment.NewLine}{output}"); + return string.Empty; + } + + private static async Task<(int ExitCode, string Output, string Error)> RunDotNetAsync(string workingDirectory, params string[] arguments) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = ScaffoldCliHelper.GetDotNetPath(), + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + foreach (var argument in arguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.Start(); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + await Task.WhenAll(outputTask, errorTask); + await process.WaitForExitAsync(); + return (process.ExitCode, outputTask.Result, errorTask.Result); + } + + private static SortedDictionary GetSourceHashes(string projectDirectory) + { + return new SortedDictionary( + Directory.GetFiles(projectDirectory, "*", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .ToDictionary( + path => Path.GetRelativePath(projectDirectory, path), + path => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))), + StringComparer.Ordinal); + } + + private static int GetAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/AddIdentityMigrationStepTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/AddIdentityMigrationStepTests.cs new file mode 100644 index 0000000000..ca5e3edf87 --- /dev/null +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/AddIdentityMigrationStepTests.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.DotNet.Tools.Scaffold.AspNet.ScaffoldSteps; +using Xunit; + +namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.ScaffoldSteps; + +public class AddIdentityMigrationStepTests +{ + [Fact] + public void GetEfDesignPackageVersion_ReturnsVersion() + { + const string assetsContent = """ +{ + "libraries": { + "Microsoft.EntityFrameworkCore.Design/11.0.0-rc.1.26425.128": { + "type": "package" + } + } +} +"""; + + var result = AddIdentityMigrationStep.GetEfDesignPackageVersion(assetsContent); + + Assert.Equal("11.0.0-rc.1.26425.128", result); + } + + [Fact] + public void GetEfDesignPackageVersion_ReturnsNullWhenPackageIsMissing() + { + const string assetsContent = """{ "libraries": {} }"""; + + var result = AddIdentityMigrationStep.GetEfDesignPackageVersion(assetsContent); + + Assert.Null(result); + } +} diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs new file mode 100644 index 0000000000..1d4d29413a --- /dev/null +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.DotNet.Scaffolding.Core.Scaffolders; +using Microsoft.DotNet.Scaffolding.Internal.Services; +using Microsoft.DotNet.Tools.Scaffold.AspNet.ScaffoldSteps; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.ScaffoldSteps; + +public class ConfigureIdentityNavigationStepTests +{ + [Theory] + [InlineData(false, "Views")] + [InlineData(true, "Pages")] + public async Task ExecuteAsync_AddsLoginPartialAndLayoutReference(bool isRazorPages, string hostFolder) + { + var projectDirectory = Path.Combine("test", "project"); + var projectPath = Path.Combine(projectDirectory, "TestProject.csproj"); + var sharedDirectory = Path.Combine(projectDirectory, hostFolder, "Shared"); + var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); + var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); + var layoutContent = ""; + var writtenFiles = new Dictionary(); + var fileSystem = new Mock(); + fileSystem.Setup(fs => fs.FileExists(layoutPath)).Returns(true); + fileSystem.Setup(fs => fs.FileExists(loginPartialPath)).Returns(false); + fileSystem.Setup(fs => fs.ReadAllText(layoutPath)).Returns(layoutContent); + fileSystem.Setup(fs => fs.WriteAllText(It.IsAny(), It.IsAny())) + .Callback((path, content) => writtenFiles[path] = content); + + var step = new ConfigureIdentityNavigationStep( + NullLogger.Instance, + fileSystem.Object) + { + ProjectPath = projectPath, + IsRazorPages = isRazorPages, + UserClassName = "ApplicationUser", + UserClassNamespace = "TestProject.Data" + }; + + var result = await step.ExecuteAsync(new ScaffolderContext(Mock.Of())); + + Assert.True(result); + Assert.Contains("@inject SignInManager", writtenFiles[loginPartialPath]); + Assert.Contains("", writtenFiles[layoutPath]); + } + + [Fact] + public async Task ExecuteAsync_DoesNotOverwriteExistingNavigation() + { + var projectDirectory = Path.Combine("test", "project"); + var projectPath = Path.Combine(projectDirectory, "TestProject.csproj"); + var sharedDirectory = Path.Combine(projectDirectory, "Views", "Shared"); + var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); + var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); + var fileSystem = new Mock(); + fileSystem.Setup(fs => fs.FileExists(layoutPath)).Returns(true); + fileSystem.Setup(fs => fs.FileExists(loginPartialPath)).Returns(true); + fileSystem.Setup(fs => fs.ReadAllText(layoutPath)).Returns(""); + + var step = new ConfigureIdentityNavigationStep( + NullLogger.Instance, + fileSystem.Object) + { + ProjectPath = projectPath, + UserClassName = "ApplicationUser", + UserClassNamespace = "TestProject.Data" + }; + + var result = await step.ExecuteAsync(new ScaffolderContext(Mock.Of())); + + Assert.True(result); + fileSystem.Verify(fs => fs.WriteAllText(It.IsAny(), It.IsAny()), Times.Never); + } +} From bad98441de506fda36dda3714ea7219b4b5f7683 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 17 Sep 2026 23:51:13 -0700 Subject: [PATCH 2/9] Test Identity scaffolder rerun behavior Verify that a second scaffolding pass leaves MVC and Razor Pages projects created with the default Identity UI unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c90ed2f5-6b56-45f7-8157-d3fa618a7ffc --- .../Identity/IdentityEndToEndNet10Tests.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs index 8938d757e0..b619bd359a 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs @@ -81,6 +81,60 @@ public async Task ScaffoldIdentity_ConfiguresCleanProject(string templateName, s } } + [Theory] + [InlineData("mvc")] + [InlineData("webapp")] + public async Task ScaffoldIdentity_SecondRunDoesNotChangeProjectWithDefaultIdentityUi(string templateName) + { + var projectName = templateName == "mvc" ? "MvcIdentity" : "RazorIdentity"; + var testDirectory = Path.Combine(Path.GetTempPath(), nameof(IdentityEndToEndNet10Tests), Guid.NewGuid().ToString("N")); + var projectDirectory = Path.Combine(testDirectory, projectName); + var projectPath = Path.Combine(projectDirectory, $"{projectName}.csproj"); + + Directory.CreateDirectory(testDirectory); + try + { + var createResult = await RunDotNetAsync( + testDirectory, + "new", templateName, + "--name", projectName, + "--output", projectDirectory, + "--framework", "net10.0", + "--auth", "Individual", + "--use-local-db", "false"); + Assert.True(createResult.ExitCode == 0, $"Project creation failed.{Environment.NewLine}{createResult.Output}{Environment.NewLine}{createResult.Error}"); + + var firstScaffoldResult = await ScaffoldCliHelper.RunScaffoldAsync( + "net10.0", + "identity", + "--project", projectPath, + "--dataContext", "ApplicationDbContext", + "--dbProvider", "sqlite-efcore"); + Assert.True(firstScaffoldResult.ExitCode == 0, $"Initial Identity scaffolding failed.{Environment.NewLine}{firstScaffoldResult.Output}{Environment.NewLine}{firstScaffoldResult.Error}"); + + var sourceHashes = GetSourceHashes(projectDirectory); + var secondScaffoldResult = await ScaffoldCliHelper.RunScaffoldAsync( + "net10.0", + "identity", + "--project", projectPath, + "--dataContext", "ApplicationDbContext", + "--dbProvider", "sqlite-efcore"); + Assert.True(secondScaffoldResult.ExitCode == 0, $"Repeated Identity scaffolding failed.{Environment.NewLine}{secondScaffoldResult.Output}{Environment.NewLine}{secondScaffoldResult.Error}"); + Assert.Equal(sourceHashes, GetSourceHashes(projectDirectory)); + } + finally + { + try + { + Directory.Delete(testDirectory, recursive: true); + } + catch + { + // Best-effort cleanup; preserve any test failure. + } + } + } + private static void AssertConfiguredProject(string projectDirectory, string hostFolder) { var programContent = File.ReadAllText(Path.Combine(projectDirectory, "Program.cs")); From aa0411237db728dc578cd441860b33aec81cb6e1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 18 Sep 2026 10:23:47 -0700 Subject: [PATCH 3/9] Align .NET 11 Identity UI templates Update the regular Identity scaffolder output to match the default Identity UI behavior and markup in dotnet/aspnetcore release/11.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c90ed2f5-6b56-45f7-8157-d3fa618a7ffc --- .../Pages/Account/ConfirmEmailChangeModel.cs | 4 +- .../Pages/Account/ConfirmEmailChangeModel.tt | 1 + .../Pages/Account/ConfirmEmailModel.cs | 4 +- .../Pages/Account/ConfirmEmailModel.tt | 1 + .../Pages/Account/ExternalLoginModel.cs | 127 +++++++++--------- .../Pages/Account/ExternalLoginModel.tt | 13 +- .../Pages/Account/ForgotPasswordModel.cs | 13 +- .../Pages/Account/ForgotPasswordModel.tt | 5 +- .../net11.0/Identity/Pages/Account/Login.cs | 4 +- .../net11.0/Identity/Pages/Account/Login.tt | 4 +- .../Identity/Pages/Account/LoginModel.cs | 56 ++++---- .../Identity/Pages/Account/LoginModel.tt | 6 +- .../Pages/Account/LoginWith2faModel.cs | 56 ++++---- .../Pages/Account/LoginWith2faModel.tt | 6 +- .../Account/LoginWithRecoveryCodeModel.cs | 52 +++---- .../Account/LoginWithRecoveryCodeModel.tt | 6 +- .../Identity/Pages/Account/LogoutModel.cs | 4 +- .../Identity/Pages/Account/LogoutModel.tt | 1 + .../Pages/Account/Manage/ChangePassword.cs | 36 ++--- .../Pages/Account/Manage/ChangePassword.tt | 8 +- .../Identity/Pages/Account/Manage/Email.cs | 40 +++--- .../Identity/Pages/Account/Manage/Email.tt | 10 +- .../Pages/Account/Manage/EmailModel.cs | 42 +++--- .../Pages/Account/Manage/EmailModel.tt | 4 +- .../Account/Manage/EnableAuthenticator.cs | 2 +- .../Account/Manage/EnableAuthenticator.tt | 2 +- .../Pages/Account/Manage/ExternalLogins.cs | 1 - .../Pages/Account/Manage/ExternalLogins.tt | 1 - .../Identity/Pages/Account/Manage/Index.cs | 4 +- .../Identity/Pages/Account/Manage/Index.tt | 4 +- .../Account/Manage/ManageNavPagesModel.cs | 47 ++++++- .../Account/Manage/ManageNavPagesModel.tt | 59 ++++++++ .../Pages/Account/Manage/PersonalData.cs | 2 +- .../Pages/Account/Manage/PersonalData.tt | 2 +- .../Pages/Account/Manage/SetPassword.cs | 24 ++-- .../Pages/Account/Manage/SetPassword.tt | 6 +- .../Identity/Pages/Account/Manage/_Layout.cs | 4 +- .../Identity/Pages/Account/Manage/_Layout.tt | 4 +- .../Pages/Account/Manage/_ManageNav.cs | 37 ++--- .../Pages/Account/Manage/_ManageNav.tt | 12 +- .../Identity/Pages/Account/Register.cs | 4 +- .../Identity/Pages/Account/Register.tt | 4 +- .../Account/RegisterConfirmationModel.cs | 20 +-- .../Account/RegisterConfirmationModel.tt | 4 +- .../Identity/Pages/Account/RegisterModel.cs | 69 +++++----- .../Identity/Pages/Account/RegisterModel.tt | 10 +- .../Account/ResendEmailConfirmationModel.cs | 7 +- .../Account/ResendEmailConfirmationModel.tt | 2 +- .../Pages/Account/ResetPasswordModel.cs | 4 +- .../Pages/Account/ResetPasswordModel.tt | 1 + .../Identity/IdentityNet11IntegrationTests.cs | 49 +++++++ 51 files changed, 537 insertions(+), 351 deletions(-) diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailChangeModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailChangeModel.cs index a21cadcf10..a42c775dd3 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailChangeModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailChangeModel.cs @@ -40,8 +40,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\npublic class ConfirmEmailChangeModel : PageModel\r\n{\r\n priva" + - "te readonly UserManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class ConfirmEmailChangeModel : PageM" + + "odel\r\n{\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _userManager;\r\n private readonly SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailChangeModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailChangeModel.tt index 104967d322..dcf12f3e9a 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailChangeModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailChangeModel.tt @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.WebUtilities; using <#= Model.UserClassNamespace #>; namespace <#= Model.IdentityNamespace #>.Pages.Account; +[AllowAnonymous] public class ConfirmEmailChangeModel : PageModel { private readonly UserManager<<#= Model.UserClassName #>> _userManager; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailModel.cs index b96d8c1540..6fad24fb2e 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailModel.cs @@ -41,8 +41,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\npublic class ConfirmEmailModel : PageModel\r\n{\r\n private rea" + - "donly UserManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class ConfirmEmailModel : PageModel\r\n" + + "{\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _userManager;\r\n\r\n public ConfirmEmailModel(UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailModel.tt index 5fd689f9c7..5c046b43f9 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ConfirmEmailModel.tt @@ -19,6 +19,7 @@ using <#= Model.UserClassNamespace #>; namespace <#= Model.IdentityNamespace #>.Pages.Account; +[AllowAnonymous] public class ConfirmEmailModel : PageModel { private readonly UserManager<<#= Model.UserClassName #>> _userManager; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.cs index 02b1c735c1..63e11b0359 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.cs @@ -30,6 +30,7 @@ public virtual string TransformText() using System; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; @@ -93,68 +94,70 @@ public virtual string TransformText() "summary>\r\n [Required]\r\n [EmailAddress]\r\n public string Emai" + "l { get; set; } = default!;\r\n }\r\n \r\n public IActionResult OnGet() =" + "> RedirectToPage(\"./Login\");\r\n\r\n public IActionResult OnPost(string provider," + - " string? returnUrl = null)\r\n {\r\n // Request a redirect to the external" + - " login provider.\r\n var redirectUrl = Url.Page(\"./ExternalLogin\", pageHand" + - "ler: \"Callback\", values: new { returnUrl });\r\n var properties = _signInMa" + - "nager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);\r\n " + - " return new ChallengeResult(provider, properties);\r\n }\r\n\r\n public async Ta" + - "sk OnGetCallbackAsync(string? returnUrl = null, string? remoteErr" + - "or = null)\r\n {\r\n returnUrl = returnUrl ?? Url.Content(\"~/\");\r\n " + - "if (remoteError != null)\r\n {\r\n ErrorMessage = $\"Error from ext" + - "ernal provider: {remoteError}\";\r\n return RedirectToPage(\"./Login\", ne" + - "w { ReturnUrl = returnUrl });\r\n }\r\n var info = await _signInManage" + - "r.GetExternalLoginInfoAsync();\r\n if (info == null)\r\n {\r\n " + - " ErrorMessage = \"Error loading external login information.\";\r\n retur" + - "n RedirectToPage(\"./Login\", new { ReturnUrl = returnUrl });\r\n }\r\n\r\n " + - " // Sign in the user with this external login provider if the user already has " + - "a login.\r\n var result = await _signInManager.ExternalLoginSignInAsync(inf" + - "o.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);\r" + - "\n if (result.Succeeded)\r\n {\r\n _logger.LogInformation(\"{" + - "Name} logged in with {LoginProvider} provider.\", info.Principal.Identity?.Name, " + - "info.LoginProvider);\r\n return LocalRedirect(returnUrl);\r\n }\r\n " + - " if (result.IsLockedOut)\r\n {\r\n return RedirectToPage(\"./" + - "Lockout\");\r\n }\r\n else\r\n {\r\n // If the user does " + - "not have an account, then ask the user to create an account.\r\n Return" + - "Url = returnUrl;\r\n ProviderDisplayName = info.ProviderDisplayName;\r\n " + - " if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))\r\n " + - " {\r\n Input = new InputModel\r\n {\r\n " + - " Email = info.Principal.FindFirstValue(ClaimTypes.Email)!\r\n " + - " };\r\n }\r\n return Page();\r\n }\r\n }\r\n\r\n public " + - "async Task OnPostConfirmationAsync(string? returnUrl = null)\r\n " + - " {\r\n returnUrl = returnUrl ?? Url.Content(\"~/\");\r\n // Get the info" + - "rmation about the user from the external login provider\r\n var info = awai" + - "t _signInManager.GetExternalLoginInfoAsync();\r\n if (info == null)\r\n " + - " {\r\n ErrorMessage = \"Error loading external login information during" + - " confirmation.\";\r\n return RedirectToPage(\"./Login\", new { ReturnUrl =" + - " returnUrl });\r\n }\r\n\r\n if (ModelState.IsValid)\r\n {\r\n " + - " var user = CreateUser();\r\n\r\n await _userStore.SetUserNameAsync(u" + - "ser, Input.Email, CancellationToken.None);\r\n await _emailStore.SetEma" + - "ilAsync(user, Input.Email, CancellationToken.None);\r\n\r\n var result = " + - "await _userManager.CreateAsync(user);\r\n if (result.Succeeded)\r\n " + - " {\r\n result = await _userManager.AddLoginAsync(user, info);\r" + - "\n if (result.Succeeded)\r\n {\r\n _" + - "logger.LogInformation(\"User created an account using {Name} provider.\", info.Log" + - "inProvider);\r\n\r\n var userId = await _userManager.GetUserIdAsy" + - "nc(user);\r\n var code = await _userManager.GenerateEmailConfir" + - "mationTokenAsync(user);\r\n code = WebEncoders.Base64UrlEncode(" + - "Encoding.UTF8.GetBytes(code));\r\n var callbackUrl = Url.Page(\r" + - "\n \"/Account/ConfirmEmail\",\r\n pageH" + - "andler: null,\r\n values: new { area = \"Identity\", userId =" + - " userId, code = code },\r\n protocol: Request.Scheme)!;\r\n\r\n" + - " await _emailSender.SendEmailAsync(Input.Email, \"Confirm your" + - " email\",\r\n $\"Please confirm your account by clicking here.\");\r\n\r\n " + - " // If account confirmation is required, we need to show the link if we don\'t" + - " have a real email sender\r\n if (_userManager.Options.SignIn.R" + - "equireConfirmedAccount)\r\n {\r\n return R" + - "edirectToPage(\"./RegisterConfirmation\", new { Email = Input.Email });\r\n " + - " }\r\n\r\n await _signInManager.SignInAsync(user, isPer" + - "sistent: false, info.LoginProvider);\r\n return LocalRedirect(r" + - "eturnUrl);\r\n }\r\n }\r\n foreach (var error in " + - "result.Errors)\r\n {\r\n ModelState.AddModelError(string.E" + - "mpty, error.Description);\r\n }\r\n }\r\n\r\n ProviderDisplayNa" + - "me = info.ProviderDisplayName;\r\n ReturnUrl = returnUrl;\r\n return P" + - "age();\r\n }\r\n\r\n private "); + " [StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n {\r\n " + + " // Request a redirect to the external login provider.\r\n var redirect" + + "Url = Url.Page(\"./ExternalLogin\", pageHandler: \"Callback\", values: new { returnU" + + "rl });\r\n var properties = _signInManager.ConfigureExternalAuthenticationP" + + "roperties(provider, redirectUrl);\r\n return new ChallengeResult(provider, " + + "properties);\r\n }\r\n\r\n public async Task OnGetCallbackAsync([" + + "StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null, string? remot" + + "eError = null)\r\n {\r\n returnUrl = returnUrl ?? Url.Content(\"~/\");\r\n " + + " if (remoteError != null)\r\n {\r\n ErrorMessage = $\"Error from" + + " external provider: {remoteError}\";\r\n return RedirectToPage(\"./Login\"" + + ", new { ReturnUrl = returnUrl });\r\n }\r\n var info = await _signInMa" + + "nager.GetExternalLoginInfoAsync();\r\n if (info == null)\r\n {\r\n " + + " ErrorMessage = \"Error loading external login information.\";\r\n r" + + "eturn RedirectToPage(\"./Login\", new { ReturnUrl = returnUrl });\r\n }\r\n\r\n " + + " // Sign in the user with this external login provider if the user already " + + "has a login.\r\n var result = await _signInManager.ExternalLoginSignInAsync" + + "(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: tru" + + "e);\r\n if (result.Succeeded)\r\n {\r\n _logger.LogInformatio" + + "n(\"{Name} logged in with {LoginProvider} provider.\", info.Principal.Identity?.Na" + + "me, info.LoginProvider);\r\n return LocalRedirect(returnUrl);\r\n " + + "}\r\n if (result.IsLockedOut)\r\n {\r\n return RedirectToPage" + + "(\"./Lockout\");\r\n }\r\n else\r\n {\r\n // If the user d" + + "oes not have an account, then ask the user to create an account.\r\n Re" + + "turnUrl = returnUrl;\r\n ProviderDisplayName = info.ProviderDisplayName" + + ";\r\n if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))\r\n " + + " {\r\n Input = new InputModel\r\n {\r\n " + + " Email = info.Principal.FindFirstValue(ClaimTypes.Email)!\r\n " + + " };\r\n }\r\n return Page();\r\n }\r\n }\r\n\r\n pub" + + "lic async Task OnPostConfirmationAsync([StringSyntax(StringSyntax" + + "Attribute.Uri)] string? returnUrl = null)\r\n {\r\n returnUrl = returnUrl " + + "?? Url.Content(\"~/\");\r\n // Get the information about the user from the ex" + + "ternal login provider\r\n var info = await _signInManager.GetExternalLoginI" + + "nfoAsync();\r\n if (info == null)\r\n {\r\n ErrorMessage = \"E" + + "rror loading external login information during confirmation.\";\r\n retu" + + "rn RedirectToPage(\"./Login\", new { ReturnUrl = returnUrl });\r\n }\r\n\r\n " + + " if (ModelState.IsValid)\r\n {\r\n var user = CreateUser();\r\n\r\n " + + " await _userStore.SetUserNameAsync(user, Input.Email, CancellationToke" + + "n.None);\r\n await _emailStore.SetEmailAsync(user, Input.Email, Cancell" + + "ationToken.None);\r\n\r\n var result = await _userManager.CreateAsync(use" + + "r);\r\n if (result.Succeeded)\r\n {\r\n result = " + + "await _userManager.AddLoginAsync(user, info);\r\n if (result.Succee" + + "ded)\r\n {\r\n _logger.LogInformation(\"User create" + + "d an account using {Name} provider.\", info.LoginProvider);\r\n\r\n " + + " var userId = await _userManager.GetUserIdAsync(user);\r\n var" + + " code = await _userManager.GenerateEmailConfirmationTokenAsync(user);\r\n " + + " code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));\r\n " + + " var callbackUrl = Url.Page(\r\n \"/Account" + + "/ConfirmEmail\",\r\n pageHandler: null,\r\n " + + " values: new { area = \"Identity\", userId = userId, code = code },\r\n " + + " protocol: Request.Scheme)!;\r\n\r\n await _emailSe" + + "nder.SendEmailAsync(Input.Email, \"Confirm your email\",\r\n " + + "$\"Please confirm your account by clicking here. If you didn\'t request this email confirmation, you can i" + + "gnore this email.\");\r\n\r\n // If confirmation is required, we n" + + "eed to show the link if we don\'t have a real email sender\r\n i" + + "f (!await _signInManager.CanSignInAsync(user))\r\n {\r\n " + + " return RedirectToPage(\"./RegisterConfirmation\", new { Email = Inp" + + "ut.Email });\r\n }\r\n\r\n await _signInManager." + + "SignInAsync(user, isPersistent: false, info.LoginProvider);\r\n " + + " return LocalRedirect(returnUrl);\r\n }\r\n }\r\n " + + " foreach (var error in result.Errors)\r\n {\r\n ModelState" + + ".AddModelError(string.Empty, error.Description);\r\n }\r\n }\r\n\r\n " + + " ProviderDisplayName = info.ProviderDisplayName;\r\n ReturnUrl = retur" + + "nUrl;\r\n return Page();\r\n }\r\n\r\n private "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write(" CreateUser()\r\n {\r\n try\r\n {\r\n return Activator.Create" + "Instance<"); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.tt index c11932f9c7..162b8fb136 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.tt @@ -8,6 +8,7 @@ using System; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; @@ -93,7 +94,7 @@ public class ExternalLoginModel : PageModel public IActionResult OnGet() => RedirectToPage("./Login"); - public IActionResult OnPost(string provider, string? returnUrl = null) + public IActionResult OnPost(string provider, [StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { // Request a redirect to the external login provider. var redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl }); @@ -101,7 +102,7 @@ public class ExternalLoginModel : PageModel return new ChallengeResult(provider, properties); } - public async Task OnGetCallbackAsync(string? returnUrl = null, string? remoteError = null) + public async Task OnGetCallbackAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null, string? remoteError = null) { returnUrl = returnUrl ?? Url.Content("~/"); if (remoteError != null) @@ -143,7 +144,7 @@ public class ExternalLoginModel : PageModel } } - public async Task OnPostConfirmationAsync(string? returnUrl = null) + public async Task OnPostConfirmationAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { returnUrl = returnUrl ?? Url.Content("~/"); // Get the information about the user from the external login provider @@ -179,10 +180,10 @@ public class ExternalLoginModel : PageModel protocol: Request.Scheme)!; await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); + $"Please confirm your account by clicking here. If you didn't request this email confirmation, you can ignore this email."); - // If account confirmation is required, we need to show the link if we don't have a real email sender - if (_userManager.Options.SignIn.RequireConfirmedAccount) + // If confirmation is required, we need to show the link if we don't have a real email sender + if (!await _signInManager.CanSignInAsync(user)) { return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email }); } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.cs index 76bd442488..e7179e2148 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.cs @@ -43,8 +43,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\npublic class ForgotPasswordModel : PageModel\r\n{\r\n private r" + - "eadonly UserManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class ForgotPasswordModel : PageModel" + + "\r\n{\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _userManager;\r\n private readonly IEmailSender _emailSender;\r\n\r\n public Fo" + "rgotPasswordModel(UserManager<"); @@ -76,10 +76,11 @@ public virtual string TransformText() "\r\n \"/Account/ResetPassword\",\r\n pageHandler: null,\r" + "\n values: new { area = \"Identity\", code },\r\n proto" + "col: Request.Scheme)!;\r\n\r\n await _emailSender.SendEmailAsync(\r\n " + - " Input.Email,\r\n \"Reset Password\",\r\n $\"Ple" + - "ase reset your password by c" + - "licking here.\");\r\n\r\n return RedirectToPage(\"./ForgotPasswordConfi" + - "rmation\");\r\n }\r\n\r\n return Page();\r\n }\r\n}\r\n"); + " Input.Email,\r\n \"Reset your password\",\r\n " + + "$\"Please reset your password by clicking here. If you didn\'t request a password reset, you can ignore th" + + "is email.\");\r\n\r\n return RedirectToPage(\"./ForgotPasswordConfirmation\"" + + ");\r\n }\r\n\r\n return Page();\r\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.tt index 23749b672d..45de35db07 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.tt @@ -21,6 +21,7 @@ using <#= Model.UserClassNamespace #>; namespace <#= Model.IdentityNamespace #>.Pages.Account; +[AllowAnonymous] public class ForgotPasswordModel : PageModel { private readonly UserManager<<#= Model.UserClassName #>> _userManager; @@ -77,8 +78,8 @@ public class ForgotPasswordModel : PageModel await _emailSender.SendEmailAsync( Input.Email, - "Reset Password", - $"Please reset your password by clicking here."); + "Reset your password", + $"Please reset your password by clicking here. If you didn't request a password reset, you can ignore this email."); return RedirectToPage("./ForgotPasswordConfirmation"); } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Login.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Login.cs index 231a52627e..baca246bf2 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Login.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Login.cs @@ -26,7 +26,7 @@ public partial class Login : LoginBase public virtual string TransformText() { this.Write("@page\r\n@model LoginModel\r\n\r\n@{\r\n ViewData[\"Title\"] = \"Log in\";\r\n}\r\n\r\n

@View" + - "Data[\"Title\"]

\r\n
\r\n
\r\n \r\n
\r\n
\r\n \r\n
\r\n

Use a l" + "ocal account to log in.

\r\n
\r\n
\r\n " + @@ -53,7 +53,7 @@ public virtual string TransformText() "a>\r\n

\r\n

\r\n <" + "a id=\"resend-confirmation\" asp-page=\"./ResendEmailConfirmation\">Resend email con" + "firmation\r\n

\r\n
\r\n \r\n \r\n
\r\n
" + + "orm>\r\n \r\n
\r\n
" + "\r\n
\r\n

Use another service to log in.

\r\n " + "
\r\n @{\r\n if ((Model.ExternalLogins?.Count ?" + "? 0) == 0)\r\n {\r\n
\r\n " + diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Login.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Login.tt index 443d87c089..3d5a5897af 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Login.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Login.tt @@ -12,7 +12,7 @@

@ViewData["Title"]

-
+

Use a local account to log in.

@@ -51,7 +51,7 @@
-
+

Use another service to log in.


diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.cs index e84ea9b99a..9b30145c3a 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.cs @@ -32,6 +32,7 @@ public virtual string TransformText() using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; @@ -45,8 +46,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\npublic class LoginModel : PageModel\r\n{\r\n private readonly S" + - "ignInManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LoginModel : PageModel\r\n{\r\n " + + "private readonly SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _signInManager;\r\n private readonly ILogger _logger;\r\n\r\n publi" + "c LoginModel(SignInManager<"); @@ -85,31 +86,32 @@ public virtual string TransformText() "lt UI infrastructure and is not intended to be used\r\n /// directly fr" + "om your code. This API may change or be removed in future releases.\r\n ///" + " \r\n [Display(Name = \"Remember me?\")]\r\n public bool Remem" + - "berMe { get; set; }\r\n }\r\n\r\n public async Task OnGetAsync(string? returnUrl" + - " = null)\r\n {\r\n if (!string.IsNullOrEmpty(ErrorMessage))\r\n {\r\n " + - " ModelState.AddModelError(string.Empty, ErrorMessage);\r\n }\r\n\r\n " + - " returnUrl ??= Url.Content(\"~/\");\r\n\r\n // Clear the existing external" + - " cookie to ensure a clean login process\r\n await HttpContext.SignOutAsync(" + - "IdentityConstants.ExternalScheme);\r\n\r\n ExternalLogins = (await _signInMan" + - "ager.GetExternalAuthenticationSchemesAsync()).ToList();\r\n\r\n ReturnUrl = r" + - "eturnUrl;\r\n }\r\n\r\n public async Task OnPostAsync(string? ret" + - "urnUrl = null)\r\n {\r\n returnUrl ??= Url.Content(\"~/\");\r\n\r\n Exter" + - "nalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToLis" + - "t();\r\n\r\n if (ModelState.IsValid)\r\n {\r\n // This doesn\'t " + - "count login failures towards account lockout\r\n // To enable password " + - "failures to trigger account lockout, set lockoutOnFailure: true\r\n var" + - " result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, " + - "Input.RememberMe, lockoutOnFailure: false);\r\n if (result.Succeeded)\r\n" + - " {\r\n _logger.LogInformation(\"User logged in.\");\r\n " + - " return LocalRedirect(returnUrl);\r\n }\r\n if (resu" + - "lt.RequiresTwoFactor)\r\n {\r\n return RedirectToPage(\"./L" + - "oginWith2fa\", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });\r\n " + - " }\r\n if (result.IsLockedOut)\r\n {\r\n " + - " _logger.LogWarning(\"User account locked out.\");\r\n return Redirec" + - "tToPage(\"./Lockout\");\r\n }\r\n else\r\n {\r\n " + - " ModelState.AddModelError(string.Empty, \"Invalid login attempt.\");\r\n " + - " return Page();\r\n }\r\n }\r\n\r\n // If we got this " + - "far, something failed, redisplay form\r\n return Page();\r\n }\r\n}\r\n"); + "berMe { get; set; }\r\n }\r\n\r\n public async Task OnGetAsync([StringSyntax(Str" + + "ingSyntaxAttribute.Uri)] string? returnUrl = null)\r\n {\r\n if (!string.I" + + "sNullOrEmpty(ErrorMessage))\r\n {\r\n ModelState.AddModelError(str" + + "ing.Empty, ErrorMessage);\r\n }\r\n\r\n returnUrl ??= Url.Content(\"~/\");" + + "\r\n\r\n // Clear the existing external cookie to ensure a clean login proces" + + "s\r\n await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);\r\n\r\n" + + " ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesA" + + "sync()).ToList();\r\n\r\n ReturnUrl = returnUrl;\r\n }\r\n\r\n public async T" + + "ask OnPostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string?" + + " returnUrl = null)\r\n {\r\n returnUrl ??= Url.Content(\"~/\");\r\n\r\n E" + + "xternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).T" + + "oList();\r\n\r\n if (ModelState.IsValid)\r\n {\r\n // This does" + + "n\'t count login failures towards account lockout\r\n // To enable passw" + + "ord failures to trigger account lockout, set lockoutOnFailure: true\r\n " + + " var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Passwo" + + "rd, Input.RememberMe, lockoutOnFailure: false);\r\n if (result.Succeede" + + "d)\r\n {\r\n _logger.LogInformation(\"User logged in.\");\r\n " + + " return LocalRedirect(returnUrl);\r\n }\r\n if (" + + "result.RequiresTwoFactor)\r\n {\r\n return RedirectToPage(" + + "\"./LoginWith2fa\", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });" + + "\r\n }\r\n if (result.IsLockedOut)\r\n {\r\n " + + " _logger.LogWarning(\"User account locked out.\");\r\n return Red" + + "irectToPage(\"./Lockout\");\r\n }\r\n else\r\n {\r\n " + + " ModelState.AddModelError(string.Empty, \"Invalid login attempt.\");\r\n " + + " return Page();\r\n }\r\n }\r\n\r\n // If we got t" + + "his far, something failed, redisplay form\r\n return Page();\r\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.tt index ee7373ab5f..f9e3c9a443 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.tt @@ -10,6 +10,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; @@ -23,6 +24,7 @@ using <#= Model.UserClassNamespace #>; namespace <#= Model.IdentityNamespace #>.Pages.Account; +[AllowAnonymous] public class LoginModel : PageModel { private readonly SignInManager<<#= Model.UserClassName #>> _signInManager; @@ -90,7 +92,7 @@ public class LoginModel : PageModel public bool RememberMe { get; set; } } - public async Task OnGetAsync(string? returnUrl = null) + public async Task OnGetAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { if (!string.IsNullOrEmpty(ErrorMessage)) { @@ -107,7 +109,7 @@ public class LoginModel : PageModel ReturnUrl = returnUrl; } - public async Task OnPostAsync(string? returnUrl = null) + public async Task OnPostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { returnUrl ??= Url.Content("~/"); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.cs index 2c1440d222..d3512ffb2f 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.cs @@ -30,6 +30,7 @@ public virtual string TransformText() using System; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -41,8 +42,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\npublic class LoginWith2faModel : PageModel\r\n{\r\n private rea" + - "donly SignInManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LoginWith2faModel : PageModel\r\n" + + "{\r\n private readonly SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _signInManager;\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); @@ -80,31 +81,32 @@ public virtual string TransformText() "ly from your code. This API may change or be removed in future releases.\r\n " + " /// \r\n [Display(Name = \"Remember this machine\")]\r\n pub" + "lic bool RememberMachine { get; set; }\r\n }\r\n\r\n public async Task OnGetAsync(bool rememberMe, string? returnUrl = null)\r\n {\r\n // E" + - "nsure the user has gone through the username & password screen first\r\n va" + - "r user = await _signInManager.GetTwoFactorAuthenticationUserAsync();\r\n\r\n " + - "if (user == null)\r\n {\r\n throw new InvalidOperationException($\"" + - "Unable to load two-factor authentication user.\");\r\n }\r\n\r\n ReturnUr" + - "l = returnUrl;\r\n RememberMe = rememberMe;\r\n\r\n return Page();\r\n " + - "}\r\n\r\n public async Task OnPostAsync(bool rememberMe, string? r" + - "eturnUrl = null)\r\n {\r\n if (!ModelState.IsValid)\r\n {\r\n " + - " return Page();\r\n }\r\n\r\n returnUrl = returnUrl ?? Url.Content(\"~/\"" + - ");\r\n\r\n var user = await _signInManager.GetTwoFactorAuthenticationUserAsyn" + - "c();\r\n if (user == null)\r\n {\r\n throw new InvalidOperati" + - "onException($\"Unable to load two-factor authentication user.\");\r\n }\r\n\r\n " + - " var authenticatorCode = Input.TwoFactorCode.Replace(\" \", string.Empty).Rep" + - "lace(\"-\", string.Empty);\r\n\r\n var result = await _signInManager.TwoFactorA" + - "uthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine);\r\n" + - "\r\n var userId = await _userManager.GetUserIdAsync(user);\r\n\r\n if (r" + - "esult.Succeeded)\r\n {\r\n _logger.LogInformation(\"User with ID \'{" + - "UserId}\' logged in with 2fa.\", user.Id);\r\n return LocalRedirect(retur" + - "nUrl);\r\n }\r\n else if (result.IsLockedOut)\r\n {\r\n " + - "_logger.LogWarning(\"User with ID \'{UserId}\' account locked out.\", user.Id);\r\n " + - " return RedirectToPage(\"./Lockout\");\r\n }\r\n else\r\n {" + - "\r\n _logger.LogWarning(\"Invalid authenticator code entered for user wi" + - "th ID \'{UserId}\'.\", user.Id);\r\n ModelState.AddModelError(string.Empty" + - ", \"Invalid authenticator code.\");\r\n return Page();\r\n }\r\n }\r" + - "\n}\r\n"); + "sult> OnGetAsync(bool rememberMe, [StringSyntax(StringSyntaxAttribute.Uri)] stri" + + "ng? returnUrl = null)\r\n {\r\n // Ensure the user has gone through the us" + + "ername & password screen first\r\n var user = await _signInManager.GetTwoFa" + + "ctorAuthenticationUserAsync();\r\n\r\n if (user == null)\r\n {\r\n " + + " throw new InvalidOperationException($\"Unable to load two-factor authenticati" + + "on user.\");\r\n }\r\n\r\n ReturnUrl = returnUrl;\r\n RememberMe = r" + + "ememberMe;\r\n\r\n return Page();\r\n }\r\n\r\n public async Task OnPostAsync(bool rememberMe, [StringSyntax(StringSyntaxAttribute.Uri)] strin" + + "g? returnUrl = null)\r\n {\r\n if (!ModelState.IsValid)\r\n {\r\n " + + " return Page();\r\n }\r\n\r\n returnUrl = returnUrl ?? Url.Content(" + + "\"~/\");\r\n\r\n var user = await _signInManager.GetTwoFactorAuthenticationUser" + + "Async();\r\n if (user == null)\r\n {\r\n throw new InvalidOpe" + + "rationException($\"Unable to load two-factor authentication user.\");\r\n }\r\n" + + "\r\n var authenticatorCode = Input.TwoFactorCode.Replace(\" \", string.Empty)" + + ".Replace(\"-\", string.Empty);\r\n\r\n var result = await _signInManager.TwoFac" + + "torAuthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine" + + ");\r\n\r\n var userId = await _userManager.GetUserIdAsync(user);\r\n\r\n i" + + "f (result.Succeeded)\r\n {\r\n _logger.LogInformation(\"User with I" + + "D \'{UserId}\' logged in with 2fa.\", user.Id);\r\n return LocalRedirect(r" + + "eturnUrl);\r\n }\r\n else if (result.IsLockedOut)\r\n {\r\n " + + " _logger.LogWarning(\"User with ID \'{UserId}\' account locked out.\", user.Id);\r" + + "\n return RedirectToPage(\"./Lockout\");\r\n }\r\n else\r\n " + + " {\r\n _logger.LogWarning(\"Invalid authenticator code entered for use" + + "r with ID \'{UserId}\'.\", user.Id);\r\n ModelState.AddModelError(string.E" + + "mpty, \"Invalid authenticator code.\");\r\n return Page();\r\n }\r\n " + + " }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.tt index bb9a32e323..94e05b5234 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.tt @@ -8,6 +8,7 @@ using System; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,6 +20,7 @@ using <#= Model.UserClassNamespace #>; namespace <#= Model.IdentityNamespace #>.Pages.Account; +[AllowAnonymous] public class LoginWith2faModel : PageModel { private readonly SignInManager<<#= Model.UserClassName #>> _signInManager; @@ -78,7 +80,7 @@ public class LoginWith2faModel : PageModel public bool RememberMachine { get; set; } } - public async Task OnGetAsync(bool rememberMe, string? returnUrl = null) + public async Task OnGetAsync(bool rememberMe, [StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { // Ensure the user has gone through the username & password screen first var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); @@ -94,7 +96,7 @@ public class LoginWith2faModel : PageModel return Page(); } - public async Task OnPostAsync(bool rememberMe, string? returnUrl = null) + public async Task OnPostAsync(bool rememberMe, [StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { if (!ModelState.IsValid) { diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.cs index 343efe14f7..259abb7cf8 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.cs @@ -30,6 +30,7 @@ public virtual string TransformText() using System; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -40,8 +41,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\npublic class LoginWithRecoveryCodeModel : PageModel\r\n{\r\n pr" + - "ivate readonly SignInManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LoginWithRecoveryCodeModel : Pa" + + "geModel\r\n{\r\n private readonly SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _signInManager;\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); @@ -69,29 +70,30 @@ public virtual string TransformText() "ange or be removed in future releases.\r\n /// \r\n [BindPro" + "perty]\r\n [Required]\r\n [DataType(DataType.Text)]\r\n [Display(" + "Name = \"Recovery Code\")]\r\n public string RecoveryCode { get; set; } = def" + - "ault!;\r\n }\r\n\r\n public async Task OnGetAsync(string? returnU" + - "rl = null)\r\n {\r\n // Ensure the user has gone through the username & pa" + - "ssword screen first\r\n var user = await _signInManager.GetTwoFactorAuthent" + - "icationUserAsync();\r\n if (user == null)\r\n {\r\n throw new" + - " InvalidOperationException($\"Unable to load two-factor authentication user.\");\r\n" + - " }\r\n\r\n ReturnUrl = returnUrl;\r\n\r\n return Page();\r\n }\r\n\r\n" + - " public async Task OnPostAsync(string? returnUrl = null)\r\n " + - "{\r\n if (!ModelState.IsValid)\r\n {\r\n return Page();\r\n " + - " }\r\n\r\n var user = await _signInManager.GetTwoFactorAuthenticationUserA" + - "sync();\r\n if (user == null)\r\n {\r\n throw new InvalidOper" + - "ationException($\"Unable to load two-factor authentication user.\");\r\n }\r\n\r" + - "\n var recoveryCode = Input.RecoveryCode.Replace(\" \", string.Empty);\r\n\r\n " + - " var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recover" + - "yCode);\r\n\r\n var userId = await _userManager.GetUserIdAsync(user);\r\n\r\n " + - " if (result.Succeeded)\r\n {\r\n _logger.LogInformation(\"User w" + - "ith ID \'{UserId}\' logged in with a recovery code.\", user.Id);\r\n retur" + - "n LocalRedirect(returnUrl ?? Url.Content(\"~/\"));\r\n }\r\n if (result." + - "IsLockedOut)\r\n {\r\n _logger.LogWarning(\"User account locked out" + - ".\");\r\n return RedirectToPage(\"./Lockout\");\r\n }\r\n else\r\n" + - " {\r\n _logger.LogWarning(\"Invalid recovery code entered for use" + - "r with ID \'{UserId}\' \", user.Id);\r\n ModelState.AddModelError(string.E" + - "mpty, \"Invalid recovery code entered.\");\r\n return Page();\r\n }\r" + - "\n }\r\n}\r\n"); + "ault!;\r\n }\r\n\r\n public async Task OnGetAsync([StringSyntax(S" + + "tringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n {\r\n // Ensure t" + + "he user has gone through the username & password screen first\r\n var user " + + "= await _signInManager.GetTwoFactorAuthenticationUserAsync();\r\n if (user " + + "== null)\r\n {\r\n throw new InvalidOperationException($\"Unable to" + + " load two-factor authentication user.\");\r\n }\r\n\r\n ReturnUrl = retur" + + "nUrl;\r\n\r\n return Page();\r\n }\r\n\r\n public async Task O" + + "nPostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n" + + " {\r\n if (!ModelState.IsValid)\r\n {\r\n return Page();\r\n" + + " }\r\n\r\n var user = await _signInManager.GetTwoFactorAuthenticationU" + + "serAsync();\r\n if (user == null)\r\n {\r\n throw new Invalid" + + "OperationException($\"Unable to load two-factor authentication user.\");\r\n " + + "}\r\n\r\n var recoveryCode = Input.RecoveryCode.Replace(\" \", string.Empty);\r\n" + + "\r\n var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(rec" + + "overyCode);\r\n\r\n var userId = await _userManager.GetUserIdAsync(user);\r\n\r\n" + + " if (result.Succeeded)\r\n {\r\n _logger.LogInformation(\"Us" + + "er with ID \'{UserId}\' logged in with a recovery code.\", user.Id);\r\n r" + + "eturn LocalRedirect(returnUrl ?? Url.Content(\"~/\"));\r\n }\r\n if (res" + + "ult.IsLockedOut)\r\n {\r\n _logger.LogWarning(\"User account locked" + + " out.\");\r\n return RedirectToPage(\"./Lockout\");\r\n }\r\n el" + + "se\r\n {\r\n _logger.LogWarning(\"Invalid recovery code entered for" + + " user with ID \'{UserId}\' \", user.Id);\r\n ModelState.AddModelError(stri" + + "ng.Empty, \"Invalid recovery code entered.\");\r\n return Page();\r\n " + + " }\r\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.tt index fbcb2faa75..72910ad06a 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.tt @@ -8,6 +8,7 @@ using System; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -18,6 +19,7 @@ using <#= Model.UserClassNamespace #>; namespace <#= Model.IdentityNamespace #>.Pages.Account; +[AllowAnonymous] public class LoginWithRecoveryCodeModel : PageModel { private readonly SignInManager<<#= Model.UserClassName #>> _signInManager; @@ -64,7 +66,7 @@ public class LoginWithRecoveryCodeModel : PageModel public string RecoveryCode { get; set; } = default!; } - public async Task OnGetAsync(string? returnUrl = null) + public async Task OnGetAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { // Ensure the user has gone through the username & password screen first var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); @@ -78,7 +80,7 @@ public class LoginWithRecoveryCodeModel : PageModel return Page(); } - public async Task OnPostAsync(string? returnUrl = null) + public async Task OnPostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { if (!ModelState.IsValid) { diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LogoutModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LogoutModel.cs index 5af02d2333..0fe174dfc7 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LogoutModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LogoutModel.cs @@ -39,8 +39,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\npublic class LogoutModel : PageModel\r\n{\r\n private readonly " + - "SignInManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LogoutModel : PageModel\r\n{\r\n " + + " private readonly SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _signInManager;\r\n private readonly ILogger _logger;\r\n\r\n publ" + "ic LogoutModel(SignInManager<"); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LogoutModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LogoutModel.tt index 5ce49ff253..854065ca5e 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LogoutModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LogoutModel.tt @@ -17,6 +17,7 @@ using <#= Model.UserClassNamespace #>; namespace <#= Model.IdentityNamespace #>.Pages.Account; +[AllowAnonymous] public class LogoutModel : PageModel { private readonly SignInManager<<#= Model.UserClassName #>> _signInManager; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ChangePassword.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ChangePassword.cs index 70cb4106d9..d4b1872f77 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ChangePassword.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ChangePassword.cs @@ -28,27 +28,27 @@ public virtual string TransformText() this.Write("@page\r\n@model ChangePasswordModel\r\n@{\r\n ViewData[\"Title\"] = \"Change password\";" + "\r\n ViewData[\"ActivePage\"] = ManageNavPages.ChangePassword;\r\n}\r\n\r\n

@ViewDat" + "a[\"Title\"]

\r\n\r\n
\r\n
\r\n
\r\n
\r\n \r\n
\r\n
\r\n " + " \r\n
\r\n
\r\n \r\n \r\n \r\n
\r\n
\r\n \r\n \r\n \r\n
\r\n Update password\r\n \r\n
\r\n
\r\n\r\n@section Scripts {\r\n \r\n}\r\n"); + "current-password\" aria-required=\"true\" placeholder=\"Enter the old password\" />\r\n" + + " \r\n" + + " \r\n
\r\n
\r\n " + + " \r\n " + + " \r\n " + + " <" + + "/span>\r\n
\r\n
\r\n " + + " \r\n " + + "
\r\n \r\n \r\n \r\n
\r\n\r\n@section Scripts {\r\n \r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ChangePassword.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ChangePassword.tt index 021de54a64..6938753f95 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ChangePassword.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ChangePassword.tt @@ -13,21 +13,21 @@

@ViewData["Title"]

-
+
- +
- +
- +
diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Email.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Email.cs index f0bae88e4f..c3b72b1412 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Email.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Email.cs @@ -28,29 +28,29 @@ public virtual string TransformText() this.Write("@page\r\n@model EmailModel\r\n@{\r\n ViewData[\"Title\"] = \"Manage Email\";\r\n ViewDa" + "ta[\"ActivePage\"] = ManageNavPages.Email;\r\n}\r\n\r\n

@ViewData[\"Title\"]

\r\n\r\n
\r\n
\r\n \r\n \r\n \r\n
\r\n " + " @if (Model.IsEmailConfirmed)\r\n {\r\n
\r\n \r\n " + - "
\r\n ?\r\n " + - "
\r\n \r\n
\r\n }\r\n else\r\n " + - " {\r\n
\r\n \r\n
\r\n }\r\n
\r\n \r\n \r\n" + - " \r\n
\r\n \r\n \r\n
\r\n
\r\n\r\n@section Scripts {\r\n " + - "\r\n}\r\n"); + "form-control\" placeholder=\"Enter your email\" disabled />\r\n " + + "
\r\n ✓\r\n " + + "
\r\n \r\n
\r\n }\r\n else\r\n " + + " {\r\n
\r\n " + + "\r\n \r\n " + + " \r\n " + + "
\r\n }\r\n
\r" + + "\n \r\n " + + " \r\n <" + + "span asp-validation-for=\"Input.NewEmail\" class=\"text-danger\">\r\n " + + "
\r\n \r\n" + + " \r\n
\r\n
\r\n\r\n@section Scripts {\r\n \r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Email.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Email.tt index 18be2e1790..2ae8eb82f9 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Email.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Email.tt @@ -13,15 +13,15 @@

@ViewData["Title"]

-
+
@if (Model.IsEmailConfirmed) {
- +
- ✓ + ✓
@@ -29,13 +29,13 @@ else {
- +
}
- +
diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.cs index c93c98a976..b7f2e6ce3b 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.cs @@ -103,26 +103,28 @@ public virtual string TransformText() " protocol: Request.Scheme)!;\r\n await _emailSender.SendEmailAsync(\r\n " + " Input.NewEmail,\r\n \"Confirm your email\",\r\n " + " $\"Please confirm your account by clicking here.\");\r\n\r\n StatusMessage = \"Confirmation li" + - "nk to change email sent. Please check your email.\";\r\n return Redirect" + - "ToPage();\r\n }\r\n\r\n StatusMessage = \"Your email is unchanged.\";\r\n " + - " return RedirectToPage();\r\n }\r\n\r\n public async Task OnP" + - "ostSendVerificationEmailAsync()\r\n {\r\n var user = await _userManager.Ge" + - "tUserAsync(User);\r\n if (user == null)\r\n {\r\n return NotF" + - "ound($\"Unable to load user with ID \'{_userManager.GetUserId(User)}\'.\");\r\n " + - " }\r\n\r\n if (!ModelState.IsValid)\r\n {\r\n await LoadAsync(u" + - "ser);\r\n return Page();\r\n }\r\n\r\n var userId = await _user" + - "Manager.GetUserIdAsync(user);\r\n var email = await _userManager.GetEmailAs" + - "ync(user);\r\n var code = await _userManager.GenerateEmailConfirmationToken" + - "Async(user);\r\n code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(" + - "code));\r\n var callbackUrl = Url.Page(\r\n \"/Account/ConfirmEmail" + - "\",\r\n pageHandler: null,\r\n values: new { area = \"Identity\"," + - " userId = userId, code = code },\r\n protocol: Request.Scheme)!;\r\n " + - " await _emailSender.SendEmailAsync(\r\n email!,\r\n \"Confirm" + - " your email\",\r\n $\"Please confirm your account by clicking here.\");\r\n\r\n StatusMessage =" + - " \"Verification email sent. Please check your email.\";\r\n return RedirectTo" + - "Page();\r\n }\r\n}\r\n"); + "backUrl)}\'>clicking here. If you didn\'t request this email confirmation, you" + + " can ignore this email.\");\r\n\r\n StatusMessage = \"Confirmation link to " + + "change email sent. Please check your email.\";\r\n return RedirectToPage" + + "();\r\n }\r\n\r\n StatusMessage = \"Your email is unchanged.\";\r\n r" + + "eturn RedirectToPage();\r\n }\r\n\r\n public async Task OnPostSen" + + "dVerificationEmailAsync()\r\n {\r\n var user = await _userManager.GetUserA" + + "sync(User);\r\n if (user == null)\r\n {\r\n return NotFound($" + + "\"Unable to load user with ID \'{_userManager.GetUserId(User)}\'.\");\r\n }\r\n\r\n" + + " if (!ModelState.IsValid)\r\n {\r\n await LoadAsync(user);\r" + + "\n return Page();\r\n }\r\n\r\n var userId = await _userManage" + + "r.GetUserIdAsync(user);\r\n var email = await _userManager.GetEmailAsync(us" + + "er);\r\n var code = await _userManager.GenerateEmailConfirmationTokenAsync(" + + "user);\r\n code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code))" + + ";\r\n var callbackUrl = Url.Page(\r\n \"/Account/ConfirmEmail\",\r\n " + + " pageHandler: null,\r\n values: new { area = \"Identity\", userI" + + "d = userId, code = code },\r\n protocol: Request.Scheme)!;\r\n awa" + + "it _emailSender.SendEmailAsync(\r\n email!,\r\n \"Confirm your " + + "email\",\r\n $\"Please confirm your account by clicking here. If you didn\'t request this email co" + + "nfirmation, you can ignore this email.\");\r\n\r\n StatusMessage = \"Verificati" + + "on email sent. Please check your email.\";\r\n return RedirectToPage();\r\n " + + " }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.tt index d68f8157ca..14ae9743a6 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.tt @@ -131,7 +131,7 @@ public class EmailModel : PageModel await _emailSender.SendEmailAsync( Input.NewEmail, "Confirm your email", - $"Please confirm your account by clicking here."); + $"Please confirm your account by clicking here. If you didn't request this email confirmation, you can ignore this email."); StatusMessage = "Confirmation link to change email sent. Please check your email."; return RedirectToPage(); @@ -167,7 +167,7 @@ public class EmailModel : PageModel await _emailSender.SendEmailAsync( email!, "Confirm your email", - $"Please confirm your account by clicking here."); + $"Please confirm your account by clicking here. If you didn't request this email confirmation, you can ignore this email."); StatusMessage = "Verification email sent. Please check your email."; return RedirectToPage(); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EnableAuthenticator.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EnableAuthenticator.cs index bbf230a050..fbc1520d60 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EnableAuthenticator.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EnableAuthenticator.cs @@ -46,7 +46,7 @@ public virtual string TransformText() "

\r\n Once you have scanned the QR code or input the key" + " above, your two factor authentication app will provide you\r\n wit" + "h a unique code. Enter the code in the confirmation box below.\r\n

" + - "\r\n
\r\n
\r\n " + + "\r\n
\r\n
\r\n " + " \r\n
\r\n
-
+
diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ExternalLogins.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ExternalLogins.cs index 34deabc5e3..2aaa3beac8 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ExternalLogins.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ExternalLogins.cs @@ -25,7 +25,6 @@ public partial class ExternalLogins : ExternalLoginsBase /// public virtual string TransformText() { - this.Write("\r\n"); this.Write("@page\r\n@model ExternalLoginsModel\r\n@{\r\n ViewData[\"Title\"] = \"Manage your exter" + "nal logins\";\r\n ViewData[\"ActivePage\"] = ManageNavPages.ExternalLogins;\r\n}\r\n\r\n" + "\r\n@if (Model.CurrentLogins?" + diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ExternalLogins.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ExternalLogins.tt index 9ab42c2df0..737504c0cf 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ExternalLogins.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ExternalLogins.tt @@ -1,4 +1,3 @@ - <#@ template hostSpecific="true" linePragmas="false" #> <#@ parameter type="Microsoft.DotNet.Tools.Scaffold.AspNet.Models.IdentityModel" name="Model" #> <#@ import namespace="System.Collections.Generic" #> diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Index.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Index.cs index c62d9a33b9..82bfc086e4 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Index.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Index.cs @@ -39,11 +39,11 @@ @model IndexModel
- +
- +
diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Index.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Index.tt index 416be235bf..cc3773173a 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Index.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/Index.tt @@ -17,11 +17,11 @@
- +
- +
diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ManageNavPagesModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ManageNavPagesModel.cs index 64c0a5ec80..4fb7c2f391 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ManageNavPagesModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ManageNavPagesModel.cs @@ -107,7 +107,52 @@ public virtual string TransformText() " viewContext, string page)\r\n {\r\n var activePage = viewContext.ViewData" + "[\"ActivePage\"] as string\r\n ?? Path.GetFileNameWithoutExtension(viewCo" + "ntext.ActionDescriptor.DisplayName);\r\n return string.Equals(activePage, p" + - "age, StringComparison.OrdinalIgnoreCase) ? \"active\" : null;\r\n }\r\n}\r\n"); + "age, StringComparison.OrdinalIgnoreCase) ? \"active\" : null;\r\n }\r\n\r\n /// \r\n /// This API supports the ASP.NET Core Identity default UI infr" + + "astructure and is not intended to be used\r\n /// directly from your code. " + + "This API may change or be removed in future releases.\r\n /// \r\n p" + + "ublic static string? IndexAriaCurrent(ViewContext viewContext) => AriaCurrent(vi" + + "ewContext, Index);\r\n\r\n /// \r\n /// This API supports the ASP.N" + + "ET Core Identity default UI infrastructure and is not intended to be used\r\n /" + + "// directly from your code. This API may change or be removed in future rele" + + "ases.\r\n /// \r\n public static string? EmailAriaCurrent(ViewContex" + + "t viewContext) => AriaCurrent(viewContext, Email);\r\n\r\n /// \r\n ///" + + " This API supports the ASP.NET Core Identity default UI infrastructure and i" + + "s not intended to be used\r\n /// directly from your code. This API may cha" + + "nge or be removed in future releases.\r\n /// \r\n public static str" + + "ing? ChangePasswordAriaCurrent(ViewContext viewContext) => AriaCurrent(viewConte" + + "xt, ChangePassword);\r\n\r\n /// \r\n /// This API supports the ASP" + + ".NET Core Identity default UI infrastructure and is not intended to be used\r\n " + + " /// directly from your code. This API may change or be removed in future re" + + "leases.\r\n /// \r\n public static string? DownloadPersonalDataAriaC" + + "urrent(ViewContext viewContext) => AriaCurrent(viewContext, DownloadPersonalData" + + ");\r\n\r\n /// \r\n /// This API supports the ASP.NET Core Identity" + + " default UI infrastructure and is not intended to be used\r\n /// directly " + + "from your code. This API may change or be removed in future releases.\r\n /// <" + + "/summary>\r\n public static string? DeletePersonalDataAriaCurrent(ViewContext v" + + "iewContext) => AriaCurrent(viewContext, DeletePersonalData);\r\n\r\n /// \r\n /// This API supports the ASP.NET Core Identity default UI infrastruc" + + "ture and is not intended to be used\r\n /// directly from your code. This A" + + "PI may change or be removed in future releases.\r\n /// \r\n public " + + "static string? ExternalLoginsAriaCurrent(ViewContext viewContext) => AriaCurrent" + + "(viewContext, ExternalLogins);\r\n\r\n /// \r\n /// This API suppor" + + "ts the ASP.NET Core Identity default UI infrastructure and is not intended to be" + + " used\r\n /// directly from your code. This API may change or be removed in" + + " future releases.\r\n /// \r\n public static string? PersonalDataAri" + + "aCurrent(ViewContext viewContext) => AriaCurrent(viewContext, PersonalData);\r\n\r\n" + + " /// \r\n /// This API supports the ASP.NET Core Identity defau" + + "lt UI infrastructure and is not intended to be used\r\n /// directly from y" + + "our code. This API may change or be removed in future releases.\r\n /// \r\n public static string? TwoFactorAuthenticationAriaCurrent(ViewContext vi" + + "ewContext) => AriaCurrent(viewContext, TwoFactorAuthentication);\r\n\r\n /// \r\n /// This API supports the ASP.NET Core Identity default UI infras" + + "tructure and is not intended to be used\r\n /// directly from your code. Th" + + "is API may change or be removed in future releases.\r\n /// \r\n pub" + + "lic static string? AriaCurrent(ViewContext viewContext, string page)\r\n {\r\n " + + " var activePage = viewContext.ViewData[\"ActivePage\"] as string\r\n " + + "?? Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName);\r\n" + + " return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCas" + + "e) ? \"page\" : null;\r\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ManageNavPagesModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ManageNavPagesModel.tt index 4b8987b757..85a0672de5 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ManageNavPagesModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/ManageNavPagesModel.tt @@ -123,4 +123,63 @@ public static class ManageNavPages ?? Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName); return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string? IndexAriaCurrent(ViewContext viewContext) => AriaCurrent(viewContext, Index); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string? EmailAriaCurrent(ViewContext viewContext) => AriaCurrent(viewContext, Email); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string? ChangePasswordAriaCurrent(ViewContext viewContext) => AriaCurrent(viewContext, ChangePassword); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string? DownloadPersonalDataAriaCurrent(ViewContext viewContext) => AriaCurrent(viewContext, DownloadPersonalData); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string? DeletePersonalDataAriaCurrent(ViewContext viewContext) => AriaCurrent(viewContext, DeletePersonalData); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string? ExternalLoginsAriaCurrent(ViewContext viewContext) => AriaCurrent(viewContext, ExternalLogins); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string? PersonalDataAriaCurrent(ViewContext viewContext) => AriaCurrent(viewContext, PersonalData); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string? TwoFactorAuthenticationAriaCurrent(ViewContext viewContext) => AriaCurrent(viewContext, TwoFactorAuthentication); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string? AriaCurrent(ViewContext viewContext, string page) + { + var activePage = viewContext.ViewData["ActivePage"] as string + ?? Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName); + return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "page" : null; + } } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/PersonalData.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/PersonalData.cs index 8435b57e89..1e033df0ce 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/PersonalData.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/PersonalData.cs @@ -44,7 +44,7 @@ @model PersonalDataModel

- Delete + Delete

diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/PersonalData.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/PersonalData.tt index 6edefa2d2b..2fc937e7a4 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/PersonalData.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/PersonalData.tt @@ -22,7 +22,7 @@

- Delete + Delete

diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/SetPassword.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/SetPassword.cs index f751cc148d..49f5d8792f 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/SetPassword.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/SetPassword.cs @@ -30,21 +30,21 @@ public virtual string TransformText() "ord\r\n\r\n

\r\n You do not have a local username/password for this site. Add a local" + "\r\n account so you can log in without an external login.\r\n

\r\n
\r\n
\r\n
\r\n
\r\n \r\n
\r\n
\r\n " + " \r\n \r\n \r\n \r\n
\r\n \r\n \r\n \r\n \r\n \r\n \r\n
\r\n
\r\n\r\n@section Scripts {\r\n " + - "\r\n}\r\n"); + "ord\" placeholder=\"Enter the new password\"/>\r\n \r\n \r\n
\r\n " + + "
\r\n \r\n \r\n \r\n
\r\n Set password\r\n " + + " \r\n
\r\n
\r\n\r\n@section Scripts {\r\n \r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/SetPassword.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/SetPassword.tt index ac8a02b164..8ae1a6570d 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/SetPassword.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/SetPassword.tt @@ -17,16 +17,16 @@ account so you can log in without an external login.

-
+
- +
- +
diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_Layout.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_Layout.cs index 034aa9062f..1120b5f5c6 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_Layout.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_Layout.cs @@ -39,10 +39,10 @@ public virtual string TransformText()

Change your account settings


-
+
-
+
@RenderBody()
diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_Layout.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_Layout.tt index 739892df73..fd53525a85 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_Layout.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_Layout.tt @@ -17,10 +17,10 @@

Change your account settings


-
+
-
+
@RenderBody()
diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.cs index c75218fbcb..5c11b54322 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.cs @@ -27,22 +27,27 @@ public virtual string TransformText() { this.Write("@inject SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); - this.Write(@"> SignInManager -@{ - var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); -} - -"); + this.Write("> SignInManager\r\n@{\r\n var hasExternalLogins = (await SignInManager.GetExternal" + + "AuthenticationSchemesAsync()).Any();\r\n}\r\n\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.tt index 16e3345d4c..bef0dcbd19 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.tt @@ -8,13 +8,13 @@ var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Register.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Register.cs index ec5ab34ce7..3ec2e60c7f 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Register.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Register.cs @@ -26,7 +26,7 @@ public partial class Register : RegisterBase public virtual string TransformText() { this.Write("@page\r\n@model RegisterModel\r\n@{\r\n ViewData[\"Title\"] = \"Register\";\r\n}\r\n\r\n

@V" + - "iewData[\"Title\"]

\r\n\r\n
\r\n
\r\n " + + "iewData[\"Title\"]\r\n\r\n
\r\n
\r\n " + "\r\n " + "

Create a new account.

\r\n
\r\n
\r\n " + @@ -45,7 +45,7 @@ public virtual string TransformText() "assword\">Confirm Password\r\n \r\n
\r\n " + " \r\n \r\n
\r\n
\r\n \r\n
\r\n
\r\n
\r\n

Use another service to register.<" + "/h3>\r\n
\r\n @{\r\n if ((Model.ExternalLog" + "ins?.Count ?? 0) == 0)\r\n {\r\n
\r\n " + diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Register.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Register.tt index 396cb6f802..9f13348c26 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Register.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Register.tt @@ -12,7 +12,7 @@

@ViewData["Title"]

-
+

Create a new account.


@@ -35,7 +35,7 @@
-
+

Use another service to register.


diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.cs index 050be7aebe..0d40567acd 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.cs @@ -65,16 +65,16 @@ public virtual string TransformText() "\n returnUrl = returnUrl ?? Url.Content(\"~/\");\r\n\r\n var user = await" + " _userManager.FindByEmailAsync(email);\r\n if (user == null)\r\n {\r\n " + " return NotFound($\"Unable to load user with email \'{email}\'.\");\r\n " + - " }\r\n\r\n Email = email;\r\n // Once you add a real email sender, you " + - "should remove this code that lets you confirm the account\r\n DisplayConfir" + - "mAccountLink = true;\r\n if (DisplayConfirmAccountLink)\r\n {\r\n " + - " var userId = await _userManager.GetUserIdAsync(user);\r\n var code" + - " = await _userManager.GenerateEmailConfirmationTokenAsync(user);\r\n co" + - "de = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));\r\n Ema" + - "ilConfirmationUrl = Url.Page(\r\n \"/Account/ConfirmEmail\",\r\n " + - " pageHandler: null,\r\n values: new { area = \"Identity\", us" + - "erId = userId, code = code, returnUrl = returnUrl },\r\n protocol: " + - "Request.Scheme);\r\n }\r\n\r\n return Page();\r\n }\r\n}\r\n"); + " }\r\n\r\n Email = email;\r\n // If the email sender is a no-op, displa" + + "y the confirm link in the page\r\n DisplayConfirmAccountLink = _sender is N" + + "oOpEmailSender;\r\n if (DisplayConfirmAccountLink)\r\n {\r\n " + + "var userId = await _userManager.GetUserIdAsync(user);\r\n var code = aw" + + "ait _userManager.GenerateEmailConfirmationTokenAsync(user);\r\n code = " + + "WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));\r\n EmailCon" + + "firmationUrl = Url.Page(\r\n \"/Account/ConfirmEmail\",\r\n " + + " pageHandler: null,\r\n values: new { area = \"Identity\", userId " + + "= userId, code = code, returnUrl = returnUrl },\r\n protocol: Reque" + + "st.Scheme);\r\n }\r\n\r\n return Page();\r\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.tt index cdd32708c3..482ec31b8f 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.tt @@ -64,8 +64,8 @@ public class RegisterConfirmationModel : PageModel } Email = email; - // Once you add a real email sender, you should remove this code that lets you confirm the account - DisplayConfirmAccountLink = true; + // If the email sender is a no-op, display the confirm link in the page + DisplayConfirmAccountLink = _sender is NoOpEmailSender; if (DisplayConfirmAccountLink) { var userId = await _userManager.GetUserIdAsync(user); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterModel.cs index 634bb166e2..ca56a2e1c7 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterModel.cs @@ -31,6 +31,7 @@ public virtual string TransformText() using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Text.Encodings.Web; @@ -48,8 +49,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\npublic class RegisterModel : PageModel\r\n{\r\n private readonl" + - "y SignInManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class RegisterModel : PageModel\r\n{\r\n " + + " private readonly SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _signInManager;\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); @@ -102,37 +103,39 @@ public virtual string TransformText() " /// \r\n [DataType(DataType.Password)]\r\n [Display(Name = " + "\"Confirm password\")]\r\n [Compare(\"Password\", ErrorMessage = \"The password " + "and confirmation password do not match.\")]\r\n public string? ConfirmPasswo" + - "rd { get; set; }\r\n }\r\n\r\n\r\n public async Task OnGetAsync(string? returnUrl " + - "= null)\r\n {\r\n ReturnUrl = returnUrl;\r\n ExternalLogins = (await " + - "_signInManager.GetExternalAuthenticationSchemesAsync()).ToList();\r\n }\r\n\r\n " + - "public async Task OnPostAsync(string? returnUrl = null)\r\n {\r\n " + - " returnUrl ??= Url.Content(\"~/\");\r\n ExternalLogins = (await _signIn" + - "Manager.GetExternalAuthenticationSchemesAsync()).ToList();\r\n if (ModelSta" + - "te.IsValid)\r\n {\r\n var user = CreateUser();\r\n\r\n awai" + - "t _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);\r\n " + - " await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None" + - ");\r\n var result = await _userManager.CreateAsync(user, Input.Password" + - ");\r\n\r\n if (result.Succeeded)\r\n {\r\n _logger." + - "LogInformation(\"User created a new account with password.\");\r\n\r\n " + - "var userId = await _userManager.GetUserIdAsync(user);\r\n var code " + - "= await _userManager.GenerateEmailConfirmationTokenAsync(user);\r\n " + - " code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));\r\n " + - " var callbackUrl = Url.Page(\r\n \"/Account/ConfirmEmail\",\r\n " + - " pageHandler: null,\r\n values: new { area = " + - "\"Identity\", userId = userId, code = code, returnUrl = returnUrl },\r\n " + - " protocol: Request.Scheme)!;\r\n\r\n await _emailSender.SendEm" + - "ailAsync(Input.Email, \"Confirm your email\",\r\n $\"Please confir" + - "m your account by clicking h" + - "ere.\");\r\n\r\n if (_userManager.Options.SignIn.RequireConfirmedA" + - "ccount)\r\n {\r\n return RedirectToPage(\"RegisterC" + - "onfirmation\", new { email = Input.Email, returnUrl = returnUrl });\r\n " + - " }\r\n else\r\n {\r\n await _signI" + - "nManager.SignInAsync(user, isPersistent: false);\r\n return Loc" + - "alRedirect(returnUrl);\r\n }\r\n }\r\n foreach (v" + - "ar error in result.Errors)\r\n {\r\n ModelState.AddModelEr" + - "ror(string.Empty, error.Description);\r\n }\r\n }\r\n\r\n // If" + - " we got this far, something failed, redisplay form\r\n return Page();\r\n " + - "}\r\n\r\n private "); + "rd { get; set; }\r\n }\r\n\r\n\r\n public async Task OnGetAsync([StringSyntax(Stri" + + "ngSyntaxAttribute.Uri)] string? returnUrl = null)\r\n {\r\n ReturnUrl = re" + + "turnUrl;\r\n ExternalLogins = (await _signInManager.GetExternalAuthenticati" + + "onSchemesAsync()).ToList();\r\n }\r\n\r\n public async Task OnPos" + + "tAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n " + + "{\r\n returnUrl ??= Url.Content(\"~/\");\r\n ExternalLogins = (await _si" + + "gnInManager.GetExternalAuthenticationSchemesAsync()).ToList();\r\n if (Mode" + + "lState.IsValid)\r\n {\r\n var user = CreateUser();\r\n\r\n " + + "await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);\r\n " + + " await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken." + + "None);\r\n var result = await _userManager.CreateAsync(user, Input.Pass" + + "word);\r\n\r\n if (result.Succeeded)\r\n {\r\n _log" + + "ger.LogInformation(\"User created a new account with password.\");\r\n\r\n " + + " var userId = await _userManager.GetUserIdAsync(user);\r\n var c" + + "ode = await _userManager.GenerateEmailConfirmationTokenAsync(user);\r\n " + + " code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));\r\n " + + " var callbackUrl = Url.Page(\r\n \"/Account/ConfirmEmail\"" + + ",\r\n pageHandler: null,\r\n values: new { are" + + "a = \"Identity\", userId = userId, code = code, returnUrl = returnUrl },\r\n " + + " protocol: Request.Scheme)!;\r\n\r\n await _emailSender.Se" + + "ndEmailAsync(Input.Email, \"Confirm your email\",\r\n $\"Please co" + + "nfirm your account by clicki" + + "ng here. If you didn\'t request this email confirmation, you can ignore this " + + "email.\");\r\n\r\n if (!await _signInManager.CanSignInAsync(user))\r\n " + + " {\r\n return RedirectToPage(\"RegisterConfirmation" + + "\", new { email = Input.Email, returnUrl = returnUrl });\r\n }\r\n " + + " else\r\n {\r\n await _signInManager.Si" + + "gnInAsync(user, isPersistent: false);\r\n return LocalRedirect(" + + "returnUrl);\r\n }\r\n }\r\n foreach (var error in" + + " result.Errors)\r\n {\r\n ModelState.AddModelError(string." + + "Empty, error.Description);\r\n }\r\n }\r\n\r\n // If we got thi" + + "s far, something failed, redisplay form\r\n return Page();\r\n }\r\n\r\n pr" + + "ivate "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write(" CreateUser()\r\n {\r\n try\r\n {\r\n return Activator.Create" + "Instance<"); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterModel.tt index 087c93c27c..685deec28b 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterModel.tt @@ -9,6 +9,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Text.Encodings.Web; @@ -26,6 +27,7 @@ using <#= Model.UserClassNamespace #>; namespace <#= Model.IdentityNamespace #>.Pages.Account; +[AllowAnonymous] public class RegisterModel : PageModel { private readonly SignInManager<<#= Model.UserClassName #>> _signInManager; @@ -105,13 +107,13 @@ public class RegisterModel : PageModel } - public async Task OnGetAsync(string? returnUrl = null) + public async Task OnGetAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { ReturnUrl = returnUrl; ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); } - public async Task OnPostAsync(string? returnUrl = null) + public async Task OnPostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null) { returnUrl ??= Url.Content("~/"); ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); @@ -137,9 +139,9 @@ public class RegisterModel : PageModel protocol: Request.Scheme)!; await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); + $"Please confirm your account by clicking here. If you didn't request this email confirmation, you can ignore this email."); - if (_userManager.Options.SignIn.RequireConfirmedAccount) + if (!await _signInManager.CanSignInAsync(user)) { return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl }); } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResendEmailConfirmationModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResendEmailConfirmationModel.cs index 1b98d4062c..13f66c2aa2 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResendEmailConfirmationModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResendEmailConfirmationModel.cs @@ -76,9 +76,10 @@ public virtual string TransformText() " code },\r\n protocol: Request.Scheme)!;\r\n await _emailSender.Se" + "ndEmailAsync(\r\n Input.Email,\r\n \"Confirm your email\",\r\n " + " $\"Please confirm your account by clicking here.\");\r\n\r\n ModelState.AddModelError(string.Em" + - "pty, \"Verification email sent. Please check your email.\");\r\n return Page(" + - ");\r\n }\r\n}\r\n"); + "llbackUrl)}\'>clicking here. If you didn\'t request this email confirmation, y" + + "ou can ignore this email.\");\r\n\r\n ModelState.AddModelError(string.Empty, \"" + + "Verification email sent. Please check your email.\");\r\n return Page();\r\n " + + " }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResendEmailConfirmationModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResendEmailConfirmationModel.tt index f9086ce36d..38f306282f 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResendEmailConfirmationModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResendEmailConfirmationModel.tt @@ -84,7 +84,7 @@ public class ResendEmailConfirmationModel : PageModel await _emailSender.SendEmailAsync( Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); + $"Please confirm your account by clicking here. If you didn't request this email confirmation, you can ignore this email."); ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); return Page(); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResetPasswordModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResetPasswordModel.cs index f963501ee1..aa3efcc942 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResetPasswordModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResetPasswordModel.cs @@ -41,8 +41,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\npublic class ResetPasswordModel : PageModel\r\n{\r\n private re" + - "adonly UserManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class ResetPasswordModel : PageModel\r" + + "\n{\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _userManager;\r\n\r\n public ResetPasswordModel(UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResetPasswordModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResetPasswordModel.tt index 7819a12f7e..ee35dfd9b9 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResetPasswordModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ResetPasswordModel.tt @@ -19,6 +19,7 @@ using <#= Model.UserClassNamespace #>; namespace <#= Model.IdentityNamespace #>.Pages.Account; +[AllowAnonymous] public class ResetPasswordModel : PageModel { private readonly UserManager<<#= Model.UserClassName #>> _userManager; diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet11IntegrationTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet11IntegrationTests.cs index 04c2e993f7..e450917a19 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet11IntegrationTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet11IntegrationTests.cs @@ -61,6 +61,55 @@ public override void Identity_Bootstrap5_HasMoreOrEqualFilesThanBootstrap4() $"Identity/Pages should contain .tt template files for {TargetFramework}"); } + [Fact] + public void Identity_TemplatesMatchNet11DefaultUIBehavior() + { + var accountDir = Path.Combine(GetActualTemplatesBasePath(), TargetFramework, "Identity", "Pages", "Account"); + var manageDir = Path.Combine(accountDir, "Manage"); + + foreach (var modelTemplate in new[] + { + "ConfirmEmailChangeModel.tt", + "ConfirmEmailModel.tt", + "ForgotPasswordModel.tt", + "LoginModel.tt", + "LoginWith2faModel.tt", + "LoginWithRecoveryCodeModel.tt", + "LogoutModel.tt", + "RegisterModel.tt", + "ResetPasswordModel.tt", + }) + { + Assert.Contains("[AllowAnonymous]", File.ReadAllText(Path.Combine(accountDir, modelTemplate))); + } + + var registerModel = File.ReadAllText(Path.Combine(accountDir, "RegisterModel.tt")); + Assert.Contains("if (!await _signInManager.CanSignInAsync(user))", registerModel); + Assert.Contains("[StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl", registerModel); + Assert.DoesNotContain("_userManager.Options.SignIn.RequireConfirmedAccount", registerModel); + + var externalLoginModel = File.ReadAllText(Path.Combine(accountDir, "ExternalLoginModel.tt")); + Assert.Contains("if (!await _signInManager.CanSignInAsync(user))", externalLoginModel); + Assert.DoesNotContain("_userManager.Options.SignIn.RequireConfirmedAccount", externalLoginModel); + + var registerConfirmationModel = File.ReadAllText(Path.Combine(accountDir, "RegisterConfirmationModel.tt")); + Assert.Contains("DisplayConfirmAccountLink = _sender is NoOpEmailSender;", registerConfirmationModel); + + var manageNav = File.ReadAllText(Path.Combine(manageDir, "_ManageNav.tt")); + Assert.Contains("aria-current=\"@ManageNavPages.IndexAriaCurrent(ViewContext)\"", manageNav); + Assert.Contains("aria-current=\"@ManageNavPages.PersonalDataAriaCurrent(ViewContext)\"", manageNav); + + var manageNavPagesModel = File.ReadAllText(Path.Combine(manageDir, "ManageNavPagesModel.tt")); + Assert.Contains("public static string? AriaCurrent(ViewContext viewContext, string page)", manageNavPagesModel); + Assert.Contains("return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? \"page\" : null;", manageNavPagesModel); + + Assert.Contains("class=\"col-lg-6\"", File.ReadAllText(Path.Combine(accountDir, "Login.tt"))); + Assert.Contains("class=\"col-lg-6\"", File.ReadAllText(Path.Combine(accountDir, "Register.tt"))); + Assert.Contains("class=\"col-xl-6\"", File.ReadAllText(Path.Combine(manageDir, "ChangePassword.tt"))); + Assert.Contains("role=\"button\"", File.ReadAllText(Path.Combine(manageDir, "PersonalData.tt"))); + Assert.Contains("✓", File.ReadAllText(Path.Combine(manageDir, "Email.tt"))); + } + [Fact(Skip = "net11.0 preview SDK not yet supported")] public async Task Scaffold_Identity_Net11_CliInvocation() { From ef447f1c81b25ceb26908a15f75c9a7f36851c93 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 18 Sep 2026 12:19:04 -0700 Subject: [PATCH 4/9] Address Identity scaffolder review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c90ed2f5-6b56-45f7-8157-d3fa618a7ffc --- .../IdentityScaffolderBuilderExtensions.cs | 27 ++++ .../ConfigureIdentityNavigationStep.cs | 24 ++-- .../identityChanges.json | 10 +- .../identityChanges.json | 10 +- .../Pages/Account/ExternalLoginModel.cs | 131 +++++++++--------- .../Pages/Account/ExternalLoginModel.tt | 7 +- .../Pages/Account/ForgotPasswordModel.cs | 15 +- .../Pages/Account/ForgotPasswordModel.tt | 9 +- .../Identity/Pages/Account/LoginModel.cs | 55 ++++---- .../Pages/Account/LoginWith2faModel.cs | 55 ++++---- .../Account/LoginWithRecoveryCodeModel.cs | 51 ++++--- .../Pages/Account/Manage/EmailModel.cs | 52 ++++--- .../Pages/Account/Manage/EmailModel.tt | 14 +- .../Pages/Account/Manage/_ManageNav.cs | 9 +- .../Pages/Account/Manage/_ManageNav.tt | 7 +- .../Account/RegisterConfirmationModel.cs | 16 ++- .../Account/RegisterConfirmationModel.tt | 20 ++- .../Identity/Pages/Account/RegisterModel.cs | 74 +++++----- .../Identity/Pages/Account/RegisterModel.tt | 7 +- .../Account/ResendEmailConfirmationModel.cs | 14 +- .../Account/ResendEmailConfirmationModel.tt | 9 +- .../identityChanges.json | 74 ++++++++++ .../identityChanges.json | Bin 2788 -> 5748 bytes .../dotnet-scaffold/dotnet-scaffold.csproj | 13 +- .../Identity/IdentityEndToEndNet10Tests.cs | 81 ++++++++++- .../Identity/IdentityIntegrationTestsBase.cs | 23 +++ .../Identity/IdentityNet11IntegrationTests.cs | 61 +++++++- .../ConfigureIdentityNavigationStepTests.cs | 61 +++++++- 28 files changed, 615 insertions(+), 314 deletions(-) create mode 100644 src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net8.0/CodeModificationConfigs/identityChanges.json diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs index 2285c2852d..7636dd83f4 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs @@ -143,6 +143,7 @@ codeModifierProperties is not null && { step.CodeModifierProperties.TryAdd(kvp.Key, kvp.Value); } + step.CodeModifierProperties["$(IdentityRegistrationCheck)"] = GetIdentityRegistrationCheck(identitySettings.Project); step.ProjectPath = identitySettings.Project; step.CodeChangeOptions = identityModel.ProjectInfo.CodeChangeOptions ?? []; @@ -157,6 +158,32 @@ codeModifierProperties is not null && return builder; } + private static string GetIdentityRegistrationCheck(string projectPath) + { + var projectDirectory = Path.GetDirectoryName(projectPath); + var programPath = string.IsNullOrEmpty(projectDirectory) ? null : Path.Combine(projectDirectory, "Program.cs"); + if (programPath is null || !File.Exists(programPath)) + { + return "__IdentityRegistrationNotFound__"; + } + + var programContent = File.ReadAllText(programPath); + foreach (var registration in new[] + { + "builder.Services.AddDefaultIdentity", + "builder.Services.AddIdentityCore", + "builder.Services.AddIdentity" + }) + { + if (programContent.Contains(registration, StringComparison.Ordinal)) + { + return registration; + } + } + + return "__IdentityRegistrationNotFound__"; + } + /// /// Adds a step to configure Identity navigation in the host application's layout. /// diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs index fc823cbf97..c867f814c2 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs @@ -36,26 +36,32 @@ public override Task ExecuteAsync(ScaffolderContext context, CancellationT return Task.FromResult(true); } - fileSystem.CreateDirectoryIfNotExists(sharedDirectory); - var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); - if (!fileSystem.FileExists(loginPartialPath)) - { - fileSystem.WriteAllText(loginPartialPath, GetLoginPartialContent()); - } - var layoutContent = fileSystem.ReadAllText(layoutPath); if (layoutContent.Contains("_LoginPartial", StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(true); } - var closingListIndex = layoutContent.IndexOf("", StringComparison.OrdinalIgnoreCase); + var navbarClassIndex = layoutContent.IndexOf("navbar-nav", StringComparison.OrdinalIgnoreCase); + var openingListIndex = navbarClassIndex < 0 + ? -1 + : layoutContent.LastIndexOf("", navbarClassIndex, StringComparison.OrdinalIgnoreCase); if (closingListIndex < 0) { - logger.LogWarning($"Identity navigation was not added to '{layoutPath}' because no navigation list was found."); + logger.LogWarning($"Identity navigation was not added to '{layoutPath}' because no navbar navigation list was found."); return Task.FromResult(true); } + fileSystem.CreateDirectoryIfNotExists(sharedDirectory); + var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); + if (!fileSystem.FileExists(loginPartialPath)) + { + fileSystem.WriteAllText(loginPartialPath, GetLoginPartialContent()); + } + var lineStartIndex = layoutContent.LastIndexOf('\n', closingListIndex); lineStartIndex = lineStartIndex < 0 ? 0 : lineStartIndex + 1; var indentation = layoutContent[lineStartIndex..closingListIndex]; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json index 9797eab9d4..bc2ae14e48 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json @@ -28,7 +28,7 @@ }, { "InsertAfter": "builder.Services.AddDbContext", - "CheckBlock": "builder.Services.AddDefaultIdentity", + "CheckBlock": "$(IdentityRegistrationCheck)", "Block": "builder.Services.AddDefaultIdentity<$(UserClassName)>(options => options.SignIn.RequireConfirmedAccount = true).AddEntityFrameworkStores<$(DbContextName)>()", "LeadingTrivia": { "Newline": true @@ -51,16 +51,16 @@ } }, { - "CheckBlock": "app.UseAuthentication", - "Block": "app.UseAuthentication()", - "InsertBefore": [ "app.UseAuthorization()", "app.MapStaticAssets", "app.MapControllerRoute", "app.MapRazorPages", "app.Run();" ], + "CheckBlock": "app.UseMigrationsEndPoint", + "Block": "if (app.Environment.IsDevelopment())\r\n{\r\n app.UseMigrationsEndPoint();\r\n}", + "InsertAfter": "var app = WebApplication.CreateBuilder.Build();", "LeadingTrivia": { "Newline": true } }, { "CheckBlock": "app.MapRazorPages", - "Block": "app.MapRazorPages()", + "Block": "app.MapRazorPages().WithStaticAssets()", "InsertBefore": [ "app.Run();" ], "LeadingTrivia": { "Newline": true diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json index 9797eab9d4..bc2ae14e48 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json @@ -28,7 +28,7 @@ }, { "InsertAfter": "builder.Services.AddDbContext", - "CheckBlock": "builder.Services.AddDefaultIdentity", + "CheckBlock": "$(IdentityRegistrationCheck)", "Block": "builder.Services.AddDefaultIdentity<$(UserClassName)>(options => options.SignIn.RequireConfirmedAccount = true).AddEntityFrameworkStores<$(DbContextName)>()", "LeadingTrivia": { "Newline": true @@ -51,16 +51,16 @@ } }, { - "CheckBlock": "app.UseAuthentication", - "Block": "app.UseAuthentication()", - "InsertBefore": [ "app.UseAuthorization()", "app.MapStaticAssets", "app.MapControllerRoute", "app.MapRazorPages", "app.Run();" ], + "CheckBlock": "app.UseMigrationsEndPoint", + "Block": "if (app.Environment.IsDevelopment())\r\n{\r\n app.UseMigrationsEndPoint();\r\n}", + "InsertAfter": "var app = WebApplication.CreateBuilder.Build();", "LeadingTrivia": { "Newline": true } }, { "CheckBlock": "app.MapRazorPages", - "Block": "app.MapRazorPages()", + "Block": "app.MapRazorPages().WithStaticAssets()", "InsertBefore": [ "app.Run();" ], "LeadingTrivia": { "Newline": true diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.cs index 63e11b0359..729a0540f9 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.cs @@ -57,7 +57,9 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _userStore;\r\n private readonly IUserEmailStore<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); - this.Write("> _emailStore;\r\n private readonly IEmailSender _emailSender;\r\n private read" + + this.Write("> _emailStore;\r\n private readonly IEmailSender<"); + this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); + this.Write("> _emailSender;\r\n private read" + "only ILogger _logger;\r\n\r\n public ExternalLoginModel(\r\n " + " SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); @@ -65,7 +67,9 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> userManager,\r\n IUserStore<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); - this.Write("> userStore,\r\n ILogger logger,\r\n IEmailSender e" + + this.Write("> userStore,\r\n ILogger logger,\r\n IEmailSender<"); + this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); + this.Write("> e" + "mailSender)\r\n {\r\n _signInManager = signInManager;\r\n _userManage" + "r = userManager;\r\n _userStore = userStore;\r\n _emailStore = GetEmai" + "lStore();\r\n _logger = logger;\r\n _emailSender = emailSender;\r\n }" + @@ -94,70 +98,65 @@ public virtual string TransformText() "summary>\r\n [Required]\r\n [EmailAddress]\r\n public string Emai" + "l { get; set; } = default!;\r\n }\r\n \r\n public IActionResult OnGet() =" + "> RedirectToPage(\"./Login\");\r\n\r\n public IActionResult OnPost(string provider," + - " [StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n {\r\n " + - " // Request a redirect to the external login provider.\r\n var redirect" + - "Url = Url.Page(\"./ExternalLogin\", pageHandler: \"Callback\", values: new { returnU" + - "rl });\r\n var properties = _signInManager.ConfigureExternalAuthenticationP" + - "roperties(provider, redirectUrl);\r\n return new ChallengeResult(provider, " + - "properties);\r\n }\r\n\r\n public async Task OnGetCallbackAsync([" + - "StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null, string? remot" + - "eError = null)\r\n {\r\n returnUrl = returnUrl ?? Url.Content(\"~/\");\r\n " + - " if (remoteError != null)\r\n {\r\n ErrorMessage = $\"Error from" + - " external provider: {remoteError}\";\r\n return RedirectToPage(\"./Login\"" + - ", new { ReturnUrl = returnUrl });\r\n }\r\n var info = await _signInMa" + - "nager.GetExternalLoginInfoAsync();\r\n if (info == null)\r\n {\r\n " + - " ErrorMessage = \"Error loading external login information.\";\r\n r" + - "eturn RedirectToPage(\"./Login\", new { ReturnUrl = returnUrl });\r\n }\r\n\r\n " + - " // Sign in the user with this external login provider if the user already " + - "has a login.\r\n var result = await _signInManager.ExternalLoginSignInAsync" + - "(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: tru" + - "e);\r\n if (result.Succeeded)\r\n {\r\n _logger.LogInformatio" + - "n(\"{Name} logged in with {LoginProvider} provider.\", info.Principal.Identity?.Na" + - "me, info.LoginProvider);\r\n return LocalRedirect(returnUrl);\r\n " + - "}\r\n if (result.IsLockedOut)\r\n {\r\n return RedirectToPage" + - "(\"./Lockout\");\r\n }\r\n else\r\n {\r\n // If the user d" + - "oes not have an account, then ask the user to create an account.\r\n Re" + - "turnUrl = returnUrl;\r\n ProviderDisplayName = info.ProviderDisplayName" + - ";\r\n if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))\r\n " + - " {\r\n Input = new InputModel\r\n {\r\n " + - " Email = info.Principal.FindFirstValue(ClaimTypes.Email)!\r\n " + - " };\r\n }\r\n return Page();\r\n }\r\n }\r\n\r\n pub" + - "lic async Task OnPostConfirmationAsync([StringSyntax(StringSyntax" + - "Attribute.Uri)] string? returnUrl = null)\r\n {\r\n returnUrl = returnUrl " + - "?? Url.Content(\"~/\");\r\n // Get the information about the user from the ex" + - "ternal login provider\r\n var info = await _signInManager.GetExternalLoginI" + - "nfoAsync();\r\n if (info == null)\r\n {\r\n ErrorMessage = \"E" + - "rror loading external login information during confirmation.\";\r\n retu" + - "rn RedirectToPage(\"./Login\", new { ReturnUrl = returnUrl });\r\n }\r\n\r\n " + - " if (ModelState.IsValid)\r\n {\r\n var user = CreateUser();\r\n\r\n " + - " await _userStore.SetUserNameAsync(user, Input.Email, CancellationToke" + - "n.None);\r\n await _emailStore.SetEmailAsync(user, Input.Email, Cancell" + - "ationToken.None);\r\n\r\n var result = await _userManager.CreateAsync(use" + - "r);\r\n if (result.Succeeded)\r\n {\r\n result = " + - "await _userManager.AddLoginAsync(user, info);\r\n if (result.Succee" + - "ded)\r\n {\r\n _logger.LogInformation(\"User create" + - "d an account using {Name} provider.\", info.LoginProvider);\r\n\r\n " + - " var userId = await _userManager.GetUserIdAsync(user);\r\n var" + - " code = await _userManager.GenerateEmailConfirmationTokenAsync(user);\r\n " + - " code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));\r\n " + - " var callbackUrl = Url.Page(\r\n \"/Account" + - "/ConfirmEmail\",\r\n pageHandler: null,\r\n " + - " values: new { area = \"Identity\", userId = userId, code = code },\r\n " + - " protocol: Request.Scheme)!;\r\n\r\n await _emailSe" + - "nder.SendEmailAsync(Input.Email, \"Confirm your email\",\r\n " + - "$\"Please confirm your account by clicking here. If you didn\'t request this email confirmation, you can i" + - "gnore this email.\");\r\n\r\n // If confirmation is required, we n" + - "eed to show the link if we don\'t have a real email sender\r\n i" + - "f (!await _signInManager.CanSignInAsync(user))\r\n {\r\n " + - " return RedirectToPage(\"./RegisterConfirmation\", new { Email = Inp" + - "ut.Email });\r\n }\r\n\r\n await _signInManager." + - "SignInAsync(user, isPersistent: false, info.LoginProvider);\r\n " + - " return LocalRedirect(returnUrl);\r\n }\r\n }\r\n " + - " foreach (var error in result.Errors)\r\n {\r\n ModelState" + - ".AddModelError(string.Empty, error.Description);\r\n }\r\n }\r\n\r\n " + - " ProviderDisplayName = info.ProviderDisplayName;\r\n ReturnUrl = retur" + - "nUrl;\r\n return Page();\r\n }\r\n\r\n private "); + " [StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n {\r\n // Request a redirect to the external" + + " login provider.\r\n var redirectUrl = Url.Page(\"./ExternalLogin\", pageHand" + + "ler: \"Callback\", values: new { returnUrl });\r\n var properties = _signInMa" + + "nager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);\r\n " + + " return new ChallengeResult(provider, properties);\r\n }\r\n\r\n public async Ta" + + "sk OnGetCallbackAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null, string? remoteErr" + + "or = null)\r\n {\r\n returnUrl = returnUrl ?? Url.Content(\"~/\");\r\n " + + "if (remoteError != null)\r\n {\r\n ErrorMessage = $\"Error from ext" + + "ernal provider: {remoteError}\";\r\n return RedirectToPage(\"./Login\", ne" + + "w { ReturnUrl = returnUrl });\r\n }\r\n var info = await _signInManage" + + "r.GetExternalLoginInfoAsync();\r\n if (info == null)\r\n {\r\n " + + " ErrorMessage = \"Error loading external login information.\";\r\n retur" + + "n RedirectToPage(\"./Login\", new { ReturnUrl = returnUrl });\r\n }\r\n\r\n " + + " // Sign in the user with this external login provider if the user already has " + + "a login.\r\n var result = await _signInManager.ExternalLoginSignInAsync(inf" + + "o.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);\r" + + "\n if (result.Succeeded)\r\n {\r\n _logger.LogInformation(\"{" + + "Name} logged in with {LoginProvider} provider.\", info.Principal.Identity?.Name, " + + "info.LoginProvider);\r\n return LocalRedirect(returnUrl);\r\n }\r\n " + + " if (result.IsLockedOut)\r\n {\r\n return RedirectToPage(\"./" + + "Lockout\");\r\n }\r\n else\r\n {\r\n // If the user does " + + "not have an account, then ask the user to create an account.\r\n Return" + + "Url = returnUrl;\r\n ProviderDisplayName = info.ProviderDisplayName;\r\n " + + " if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))\r\n " + + " {\r\n Input = new InputModel\r\n {\r\n " + + " Email = info.Principal.FindFirstValue(ClaimTypes.Email)!\r\n " + + " };\r\n }\r\n return Page();\r\n }\r\n }\r\n\r\n public " + + "async Task OnPostConfirmationAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n " + + " {\r\n returnUrl = returnUrl ?? Url.Content(\"~/\");\r\n // Get the info" + + "rmation about the user from the external login provider\r\n var info = awai" + + "t _signInManager.GetExternalLoginInfoAsync();\r\n if (info == null)\r\n " + + " {\r\n ErrorMessage = \"Error loading external login information during" + + " confirmation.\";\r\n return RedirectToPage(\"./Login\", new { ReturnUrl =" + + " returnUrl });\r\n }\r\n\r\n if (ModelState.IsValid)\r\n {\r\n " + + " var user = CreateUser();\r\n\r\n await _userStore.SetUserNameAsync(u" + + "ser, Input.Email, CancellationToken.None);\r\n await _emailStore.SetEma" + + "ilAsync(user, Input.Email, CancellationToken.None);\r\n\r\n var result = " + + "await _userManager.CreateAsync(user);\r\n if (result.Succeeded)\r\n " + + " {\r\n result = await _userManager.AddLoginAsync(user, info);\r" + + "\n if (result.Succeeded)\r\n {\r\n _" + + "logger.LogInformation(\"User created an account using {Name} provider.\", info.Log" + + "inProvider);\r\n\r\n var userId = await _userManager.GetUserIdAsy" + + "nc(user);\r\n var code = await _userManager.GenerateEmailConfir" + + "mationTokenAsync(user);\r\n code = WebEncoders.Base64UrlEncode(" + + "Encoding.UTF8.GetBytes(code));\r\n var callbackUrl = Url.Page(\r" + + "\n \"/Account/ConfirmEmail\",\r\n pageH" + + "andler: null,\r\n values: new { area = \"Identity\", userId =" + + " userId, code = code },\r\n protocol: Request.Scheme)!;\r\n\r\n" + + " await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));\r\n\r\n " + + " // If confirmation is required, we need to show the link if we don\'t" + + " have a real email sender\r\n if (!await _signInManager.CanSignInAsync(user))\r\n {\r\n return R" + + "edirectToPage(\"./RegisterConfirmation\", new { Email = Input.Email });\r\n " + + " }\r\n\r\n await _signInManager.SignInAsync(user, isPer" + + "sistent: false, info.LoginProvider);\r\n return LocalRedirect(r" + + "eturnUrl);\r\n }\r\n }\r\n foreach (var error in " + + "result.Errors)\r\n {\r\n ModelState.AddModelError(string.E" + + "mpty, error.Description);\r\n }\r\n }\r\n\r\n ProviderDisplayNa" + + "me = info.ProviderDisplayName;\r\n ReturnUrl = returnUrl;\r\n return P" + + "age();\r\n }\r\n\r\n private "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write(" CreateUser()\r\n {\r\n try\r\n {\r\n return Activator.Create" + "Instance<"); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.tt index 162b8fb136..b97006ac40 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ExternalLoginModel.tt @@ -33,7 +33,7 @@ public class ExternalLoginModel : PageModel private readonly UserManager<<#= Model.UserClassName #>> _userManager; private readonly IUserStore<<#= Model.UserClassName #>> _userStore; private readonly IUserEmailStore<<#= Model.UserClassName #>> _emailStore; - private readonly IEmailSender _emailSender; + private readonly IEmailSender<<#= Model.UserClassName #>> _emailSender; private readonly ILogger _logger; public ExternalLoginModel( @@ -41,7 +41,7 @@ public class ExternalLoginModel : PageModel UserManager<<#= Model.UserClassName #>> userManager, IUserStore<<#= Model.UserClassName #>> userStore, ILogger logger, - IEmailSender emailSender) + IEmailSender<<#= Model.UserClassName #>> emailSender) { _signInManager = signInManager; _userManager = userManager; @@ -179,8 +179,7 @@ public class ExternalLoginModel : PageModel values: new { area = "Identity", userId = userId, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here. If you didn't request this email confirmation, you can ignore this email."); + await _emailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); // If confirmation is required, we need to show the link if we don't have a real email sender if (!await _signInManager.CanSignInAsync(user)) diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.cs index e7179e2148..b439027107 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.cs @@ -46,10 +46,13 @@ public virtual string TransformText() this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class ForgotPasswordModel : PageModel" + "\r\n{\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); - this.Write("> _userManager;\r\n private readonly IEmailSender _emailSender;\r\n\r\n public Fo" + - "rgotPasswordModel(UserManager<"); + this.Write("> _userManager;\r\n private readonly IEmailSender<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); - this.Write("> userManager, IEmailSender emailSender)\r\n {\r\n _userManager = userManag" + + this.Write("> _emailSender;\r\n\r\n public ForgotPasswordModel(UserManager<"); + this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); + this.Write("> userManager, IEmailSender<"); + this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); + this.Write("> emailSender)\r\n {\r\n _userManager = userManag" + "er;\r\n _emailSender = emailSender;\r\n }\r\n\r\n /// \r\n /// " + " This API supports the ASP.NET Core Identity default UI infrastructure and is n" + "ot intended to be used\r\n /// directly from your code. This API may change" + @@ -75,11 +78,7 @@ public virtual string TransformText() "rlEncode(Encoding.UTF8.GetBytes(code));\r\n var callbackUrl = Url.Page(" + "\r\n \"/Account/ResetPassword\",\r\n pageHandler: null,\r" + "\n values: new { area = \"Identity\", code },\r\n proto" + - "col: Request.Scheme)!;\r\n\r\n await _emailSender.SendEmailAsync(\r\n " + - " Input.Email,\r\n \"Reset your password\",\r\n " + - "$\"Please reset your password by clicking here. If you didn\'t request a password reset, you can ignore th" + - "is email.\");\r\n\r\n return RedirectToPage(\"./ForgotPasswordConfirmation\"" + + "col: Request.Scheme)!;\r\n\r\n await _emailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));\r\n\r\n return RedirectToPage(\"./ForgotPasswordConfirmation\"" + ");\r\n }\r\n\r\n return Page();\r\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.tt index 45de35db07..b80140b9ab 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/ForgotPasswordModel.tt @@ -25,9 +25,9 @@ namespace <#= Model.IdentityNamespace #>.Pages.Account; public class ForgotPasswordModel : PageModel { private readonly UserManager<<#= Model.UserClassName #>> _userManager; - private readonly IEmailSender _emailSender; + private readonly IEmailSender<<#= Model.UserClassName #>> _emailSender; - public ForgotPasswordModel(UserManager<<#= Model.UserClassName #>> userManager, IEmailSender emailSender) + public ForgotPasswordModel(UserManager<<#= Model.UserClassName #>> userManager, IEmailSender<<#= Model.UserClassName #>> emailSender) { _userManager = userManager; _emailSender = emailSender; @@ -76,10 +76,7 @@ public class ForgotPasswordModel : PageModel values: new { area = "Identity", code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - Input.Email, - "Reset your password", - $"Please reset your password by clicking here. If you didn't request a password reset, you can ignore this email."); + await _emailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); return RedirectToPage("./ForgotPasswordConfirmation"); } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.cs index 9b30145c3a..bf204211e7 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginModel.cs @@ -46,8 +46,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LoginModel : PageModel\r\n{\r\n " + - "private readonly SignInManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LoginModel : PageModel\r\n{\r\n private readonly S" + + "ignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _signInManager;\r\n private readonly ILogger _logger;\r\n\r\n publi" + "c LoginModel(SignInManager<"); @@ -86,32 +86,31 @@ public virtual string TransformText() "lt UI infrastructure and is not intended to be used\r\n /// directly fr" + "om your code. This API may change or be removed in future releases.\r\n ///" + " \r\n [Display(Name = \"Remember me?\")]\r\n public bool Remem" + - "berMe { get; set; }\r\n }\r\n\r\n public async Task OnGetAsync([StringSyntax(Str" + - "ingSyntaxAttribute.Uri)] string? returnUrl = null)\r\n {\r\n if (!string.I" + - "sNullOrEmpty(ErrorMessage))\r\n {\r\n ModelState.AddModelError(str" + - "ing.Empty, ErrorMessage);\r\n }\r\n\r\n returnUrl ??= Url.Content(\"~/\");" + - "\r\n\r\n // Clear the existing external cookie to ensure a clean login proces" + - "s\r\n await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);\r\n\r\n" + - " ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesA" + - "sync()).ToList();\r\n\r\n ReturnUrl = returnUrl;\r\n }\r\n\r\n public async T" + - "ask OnPostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string?" + - " returnUrl = null)\r\n {\r\n returnUrl ??= Url.Content(\"~/\");\r\n\r\n E" + - "xternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).T" + - "oList();\r\n\r\n if (ModelState.IsValid)\r\n {\r\n // This does" + - "n\'t count login failures towards account lockout\r\n // To enable passw" + - "ord failures to trigger account lockout, set lockoutOnFailure: true\r\n " + - " var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Passwo" + - "rd, Input.RememberMe, lockoutOnFailure: false);\r\n if (result.Succeede" + - "d)\r\n {\r\n _logger.LogInformation(\"User logged in.\");\r\n " + - " return LocalRedirect(returnUrl);\r\n }\r\n if (" + - "result.RequiresTwoFactor)\r\n {\r\n return RedirectToPage(" + - "\"./LoginWith2fa\", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });" + - "\r\n }\r\n if (result.IsLockedOut)\r\n {\r\n " + - " _logger.LogWarning(\"User account locked out.\");\r\n return Red" + - "irectToPage(\"./Lockout\");\r\n }\r\n else\r\n {\r\n " + - " ModelState.AddModelError(string.Empty, \"Invalid login attempt.\");\r\n " + - " return Page();\r\n }\r\n }\r\n\r\n // If we got t" + - "his far, something failed, redisplay form\r\n return Page();\r\n }\r\n}\r\n"); + "berMe { get; set; }\r\n }\r\n\r\n public async Task OnGetAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl" + + " = null)\r\n {\r\n if (!string.IsNullOrEmpty(ErrorMessage))\r\n {\r\n " + + " ModelState.AddModelError(string.Empty, ErrorMessage);\r\n }\r\n\r\n " + + " returnUrl ??= Url.Content(\"~/\");\r\n\r\n // Clear the existing external" + + " cookie to ensure a clean login process\r\n await HttpContext.SignOutAsync(" + + "IdentityConstants.ExternalScheme);\r\n\r\n ExternalLogins = (await _signInMan" + + "ager.GetExternalAuthenticationSchemesAsync()).ToList();\r\n\r\n ReturnUrl = r" + + "eturnUrl;\r\n }\r\n\r\n public async Task OnPostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? ret" + + "urnUrl = null)\r\n {\r\n returnUrl ??= Url.Content(\"~/\");\r\n\r\n Exter" + + "nalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToLis" + + "t();\r\n\r\n if (ModelState.IsValid)\r\n {\r\n // This doesn\'t " + + "count login failures towards account lockout\r\n // To enable password " + + "failures to trigger account lockout, set lockoutOnFailure: true\r\n var" + + " result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, " + + "Input.RememberMe, lockoutOnFailure: false);\r\n if (result.Succeeded)\r\n" + + " {\r\n _logger.LogInformation(\"User logged in.\");\r\n " + + " return LocalRedirect(returnUrl);\r\n }\r\n if (resu" + + "lt.RequiresTwoFactor)\r\n {\r\n return RedirectToPage(\"./L" + + "oginWith2fa\", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });\r\n " + + " }\r\n if (result.IsLockedOut)\r\n {\r\n " + + " _logger.LogWarning(\"User account locked out.\");\r\n return Redirec" + + "tToPage(\"./Lockout\");\r\n }\r\n else\r\n {\r\n " + + " ModelState.AddModelError(string.Empty, \"Invalid login attempt.\");\r\n " + + " return Page();\r\n }\r\n }\r\n\r\n // If we got this " + + "far, something failed, redisplay form\r\n return Page();\r\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.cs index d3512ffb2f..39bb1e4beb 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWith2faModel.cs @@ -42,8 +42,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LoginWith2faModel : PageModel\r\n" + - "{\r\n private readonly SignInManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LoginWith2faModel : PageModel\r\n{\r\n private rea" + + "donly SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _signInManager;\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); @@ -81,32 +81,31 @@ public virtual string TransformText() "ly from your code. This API may change or be removed in future releases.\r\n " + " /// \r\n [Display(Name = \"Remember this machine\")]\r\n pub" + "lic bool RememberMachine { get; set; }\r\n }\r\n\r\n public async Task OnGetAsync(bool rememberMe, [StringSyntax(StringSyntaxAttribute.Uri)] stri" + - "ng? returnUrl = null)\r\n {\r\n // Ensure the user has gone through the us" + - "ername & password screen first\r\n var user = await _signInManager.GetTwoFa" + - "ctorAuthenticationUserAsync();\r\n\r\n if (user == null)\r\n {\r\n " + - " throw new InvalidOperationException($\"Unable to load two-factor authenticati" + - "on user.\");\r\n }\r\n\r\n ReturnUrl = returnUrl;\r\n RememberMe = r" + - "ememberMe;\r\n\r\n return Page();\r\n }\r\n\r\n public async Task OnPostAsync(bool rememberMe, [StringSyntax(StringSyntaxAttribute.Uri)] strin" + - "g? returnUrl = null)\r\n {\r\n if (!ModelState.IsValid)\r\n {\r\n " + - " return Page();\r\n }\r\n\r\n returnUrl = returnUrl ?? Url.Content(" + - "\"~/\");\r\n\r\n var user = await _signInManager.GetTwoFactorAuthenticationUser" + - "Async();\r\n if (user == null)\r\n {\r\n throw new InvalidOpe" + - "rationException($\"Unable to load two-factor authentication user.\");\r\n }\r\n" + - "\r\n var authenticatorCode = Input.TwoFactorCode.Replace(\" \", string.Empty)" + - ".Replace(\"-\", string.Empty);\r\n\r\n var result = await _signInManager.TwoFac" + - "torAuthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine" + - ");\r\n\r\n var userId = await _userManager.GetUserIdAsync(user);\r\n\r\n i" + - "f (result.Succeeded)\r\n {\r\n _logger.LogInformation(\"User with I" + - "D \'{UserId}\' logged in with 2fa.\", user.Id);\r\n return LocalRedirect(r" + - "eturnUrl);\r\n }\r\n else if (result.IsLockedOut)\r\n {\r\n " + - " _logger.LogWarning(\"User with ID \'{UserId}\' account locked out.\", user.Id);\r" + - "\n return RedirectToPage(\"./Lockout\");\r\n }\r\n else\r\n " + - " {\r\n _logger.LogWarning(\"Invalid authenticator code entered for use" + - "r with ID \'{UserId}\'.\", user.Id);\r\n ModelState.AddModelError(string.E" + - "mpty, \"Invalid authenticator code.\");\r\n return Page();\r\n }\r\n " + - " }\r\n}\r\n"); + "sult> OnGetAsync(bool rememberMe, [StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n {\r\n // E" + + "nsure the user has gone through the username & password screen first\r\n va" + + "r user = await _signInManager.GetTwoFactorAuthenticationUserAsync();\r\n\r\n " + + "if (user == null)\r\n {\r\n throw new InvalidOperationException($\"" + + "Unable to load two-factor authentication user.\");\r\n }\r\n\r\n ReturnUr" + + "l = returnUrl;\r\n RememberMe = rememberMe;\r\n\r\n return Page();\r\n " + + "}\r\n\r\n public async Task OnPostAsync(bool rememberMe, [StringSyntax(StringSyntaxAttribute.Uri)] string? r" + + "eturnUrl = null)\r\n {\r\n if (!ModelState.IsValid)\r\n {\r\n " + + " return Page();\r\n }\r\n\r\n returnUrl = returnUrl ?? Url.Content(\"~/\"" + + ");\r\n\r\n var user = await _signInManager.GetTwoFactorAuthenticationUserAsyn" + + "c();\r\n if (user == null)\r\n {\r\n throw new InvalidOperati" + + "onException($\"Unable to load two-factor authentication user.\");\r\n }\r\n\r\n " + + " var authenticatorCode = Input.TwoFactorCode.Replace(\" \", string.Empty).Rep" + + "lace(\"-\", string.Empty);\r\n\r\n var result = await _signInManager.TwoFactorA" + + "uthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine);\r\n" + + "\r\n var userId = await _userManager.GetUserIdAsync(user);\r\n\r\n if (r" + + "esult.Succeeded)\r\n {\r\n _logger.LogInformation(\"User with ID \'{" + + "UserId}\' logged in with 2fa.\", user.Id);\r\n return LocalRedirect(retur" + + "nUrl);\r\n }\r\n else if (result.IsLockedOut)\r\n {\r\n " + + "_logger.LogWarning(\"User with ID \'{UserId}\' account locked out.\", user.Id);\r\n " + + " return RedirectToPage(\"./Lockout\");\r\n }\r\n else\r\n {" + + "\r\n _logger.LogWarning(\"Invalid authenticator code entered for user wi" + + "th ID \'{UserId}\'.\", user.Id);\r\n ModelState.AddModelError(string.Empty" + + ", \"Invalid authenticator code.\");\r\n return Page();\r\n }\r\n }\r" + + "\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.cs index 259abb7cf8..08daa7df54 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/LoginWithRecoveryCodeModel.cs @@ -41,8 +41,8 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassNamespace)); this.Write(";\r\n\r\nnamespace "); this.Write(this.ToStringHelper.ToStringWithCulture(Model.IdentityNamespace)); - this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LoginWithRecoveryCodeModel : Pa" + - "geModel\r\n{\r\n private readonly SignInManager<"); + this.Write(".Pages.Account;\r\n\r\n[AllowAnonymous]\r\npublic class LoginWithRecoveryCodeModel : PageModel\r\n{\r\n pr" + + "ivate readonly SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _signInManager;\r\n private readonly UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); @@ -70,30 +70,29 @@ public virtual string TransformText() "ange or be removed in future releases.\r\n /// \r\n [BindPro" + "perty]\r\n [Required]\r\n [DataType(DataType.Text)]\r\n [Display(" + "Name = \"Recovery Code\")]\r\n public string RecoveryCode { get; set; } = def" + - "ault!;\r\n }\r\n\r\n public async Task OnGetAsync([StringSyntax(S" + - "tringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n {\r\n // Ensure t" + - "he user has gone through the username & password screen first\r\n var user " + - "= await _signInManager.GetTwoFactorAuthenticationUserAsync();\r\n if (user " + - "== null)\r\n {\r\n throw new InvalidOperationException($\"Unable to" + - " load two-factor authentication user.\");\r\n }\r\n\r\n ReturnUrl = retur" + - "nUrl;\r\n\r\n return Page();\r\n }\r\n\r\n public async Task O" + - "nPostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n" + - " {\r\n if (!ModelState.IsValid)\r\n {\r\n return Page();\r\n" + - " }\r\n\r\n var user = await _signInManager.GetTwoFactorAuthenticationU" + - "serAsync();\r\n if (user == null)\r\n {\r\n throw new Invalid" + - "OperationException($\"Unable to load two-factor authentication user.\");\r\n " + - "}\r\n\r\n var recoveryCode = Input.RecoveryCode.Replace(\" \", string.Empty);\r\n" + - "\r\n var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(rec" + - "overyCode);\r\n\r\n var userId = await _userManager.GetUserIdAsync(user);\r\n\r\n" + - " if (result.Succeeded)\r\n {\r\n _logger.LogInformation(\"Us" + - "er with ID \'{UserId}\' logged in with a recovery code.\", user.Id);\r\n r" + - "eturn LocalRedirect(returnUrl ?? Url.Content(\"~/\"));\r\n }\r\n if (res" + - "ult.IsLockedOut)\r\n {\r\n _logger.LogWarning(\"User account locked" + - " out.\");\r\n return RedirectToPage(\"./Lockout\");\r\n }\r\n el" + - "se\r\n {\r\n _logger.LogWarning(\"Invalid recovery code entered for" + - " user with ID \'{UserId}\' \", user.Id);\r\n ModelState.AddModelError(stri" + - "ng.Empty, \"Invalid recovery code entered.\");\r\n return Page();\r\n " + - " }\r\n }\r\n}\r\n"); + "ault!;\r\n }\r\n\r\n public async Task OnGetAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnU" + + "rl = null)\r\n {\r\n // Ensure the user has gone through the username & pa" + + "ssword screen first\r\n var user = await _signInManager.GetTwoFactorAuthent" + + "icationUserAsync();\r\n if (user == null)\r\n {\r\n throw new" + + " InvalidOperationException($\"Unable to load two-factor authentication user.\");\r\n" + + " }\r\n\r\n ReturnUrl = returnUrl;\r\n\r\n return Page();\r\n }\r\n\r\n" + + " public async Task OnPostAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? returnUrl = null)\r\n " + + "{\r\n if (!ModelState.IsValid)\r\n {\r\n return Page();\r\n " + + " }\r\n\r\n var user = await _signInManager.GetTwoFactorAuthenticationUserA" + + "sync();\r\n if (user == null)\r\n {\r\n throw new InvalidOper" + + "ationException($\"Unable to load two-factor authentication user.\");\r\n }\r\n\r" + + "\n var recoveryCode = Input.RecoveryCode.Replace(\" \", string.Empty);\r\n\r\n " + + " var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recover" + + "yCode);\r\n\r\n var userId = await _userManager.GetUserIdAsync(user);\r\n\r\n " + + " if (result.Succeeded)\r\n {\r\n _logger.LogInformation(\"User w" + + "ith ID \'{UserId}\' logged in with a recovery code.\", user.Id);\r\n retur" + + "n LocalRedirect(returnUrl ?? Url.Content(\"~/\"));\r\n }\r\n if (result." + + "IsLockedOut)\r\n {\r\n _logger.LogWarning(\"User account locked out" + + ".\");\r\n return RedirectToPage(\"./Lockout\");\r\n }\r\n else\r\n" + + " {\r\n _logger.LogWarning(\"Invalid recovery code entered for use" + + "r with ID \'{UserId}\' \", user.Id);\r\n ModelState.AddModelError(string.E" + + "mpty, \"Invalid recovery code entered.\");\r\n return Page();\r\n }\r" + + "\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.cs index b7f2e6ce3b..29d86dbaca 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.cs @@ -47,12 +47,15 @@ public virtual string TransformText() this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> _userManager;\r\n private readonly SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); - this.Write("> _signInManager;\r\n private readonly IEmailSender _emailSender;\r\n\r\n public " + - "EmailModel(\r\n UserManager<"); + this.Write("> _signInManager;\r\n private readonly IEmailSender<"); + this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); + this.Write("> _emailSender;\r\n\r\n public EmailModel(\r\n UserManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); this.Write("> userManager,\r\n SignInManager<"); this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); - this.Write("> signInManager,\r\n IEmailSender emailSender)\r\n {\r\n _userManager " + + this.Write("> signInManager,\r\n IEmailSender<"); + this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); + this.Write("> emailSender)\r\n {\r\n _userManager " + "= userManager;\r\n _signInManager = signInManager;\r\n _emailSender = " + "emailSender;\r\n }\r\n\r\n /// \r\n /// This API supports the ASP." + "NET Core Identity default UI infrastructure and is not intended to be used\r\n " + @@ -100,31 +103,24 @@ public virtual string TransformText() " var callbackUrl = Url.Page(\r\n \"/Account/ConfirmEmailChange\"" + ",\r\n pageHandler: null,\r\n values: new { area = \"Ide" + "ntity\", userId = userId, email = Input.NewEmail, code = code },\r\n " + - " protocol: Request.Scheme)!;\r\n await _emailSender.SendEmailAsync(\r\n " + - " Input.NewEmail,\r\n \"Confirm your email\",\r\n " + - " $\"Please confirm your account by clicking here. If you didn\'t request this email confirmation, you" + - " can ignore this email.\");\r\n\r\n StatusMessage = \"Confirmation link to " + - "change email sent. Please check your email.\";\r\n return RedirectToPage" + - "();\r\n }\r\n\r\n StatusMessage = \"Your email is unchanged.\";\r\n r" + - "eturn RedirectToPage();\r\n }\r\n\r\n public async Task OnPostSen" + - "dVerificationEmailAsync()\r\n {\r\n var user = await _userManager.GetUserA" + - "sync(User);\r\n if (user == null)\r\n {\r\n return NotFound($" + - "\"Unable to load user with ID \'{_userManager.GetUserId(User)}\'.\");\r\n }\r\n\r\n" + - " if (!ModelState.IsValid)\r\n {\r\n await LoadAsync(user);\r" + - "\n return Page();\r\n }\r\n\r\n var userId = await _userManage" + - "r.GetUserIdAsync(user);\r\n var email = await _userManager.GetEmailAsync(us" + - "er);\r\n var code = await _userManager.GenerateEmailConfirmationTokenAsync(" + - "user);\r\n code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code))" + - ";\r\n var callbackUrl = Url.Page(\r\n \"/Account/ConfirmEmail\",\r\n " + - " pageHandler: null,\r\n values: new { area = \"Identity\", userI" + - "d = userId, code = code },\r\n protocol: Request.Scheme)!;\r\n awa" + - "it _emailSender.SendEmailAsync(\r\n email!,\r\n \"Confirm your " + - "email\",\r\n $\"Please confirm your account by clicking here. If you didn\'t request this email co" + - "nfirmation, you can ignore this email.\");\r\n\r\n StatusMessage = \"Verificati" + - "on email sent. Please check your email.\";\r\n return RedirectToPage();\r\n " + - " }\r\n}\r\n"); + " protocol: Request.Scheme)!;\r\n await _emailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl));\r\n\r\n StatusMessage = \"Confirmation li" + + "nk to change email sent. Please check your email.\";\r\n return Redirect" + + "ToPage();\r\n }\r\n\r\n StatusMessage = \"Your email is unchanged.\";\r\n " + + " return RedirectToPage();\r\n }\r\n\r\n public async Task OnP" + + "ostSendVerificationEmailAsync()\r\n {\r\n var user = await _userManager.Ge" + + "tUserAsync(User);\r\n if (user == null)\r\n {\r\n return NotF" + + "ound($\"Unable to load user with ID \'{_userManager.GetUserId(User)}\'.\");\r\n " + + " }\r\n\r\n if (!ModelState.IsValid)\r\n {\r\n await LoadAsync(u" + + "ser);\r\n return Page();\r\n }\r\n\r\n var userId = await _user" + + "Manager.GetUserIdAsync(user);\r\n var email = await _userManager.GetEmailAs" + + "ync(user);\r\n var code = await _userManager.GenerateEmailConfirmationToken" + + "Async(user);\r\n code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(" + + "code));\r\n var callbackUrl = Url.Page(\r\n \"/Account/ConfirmEmail" + + "\",\r\n pageHandler: null,\r\n values: new { area = \"Identity\"," + + " userId = userId, code = code },\r\n protocol: Request.Scheme)!;\r\n " + + " await _emailSender.SendConfirmationLinkAsync(user, email!, HtmlEncoder.Default.Encode(callbackUrl));\r\n\r\n StatusMessage =" + + " \"Verification email sent. Please check your email.\";\r\n return RedirectTo" + + "Page();\r\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.tt index 14ae9743a6..b3aec0c20c 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/EmailModel.tt @@ -24,12 +24,12 @@ public class EmailModel : PageModel { private readonly UserManager<<#= Model.UserClassName #>> _userManager; private readonly SignInManager<<#= Model.UserClassName #>> _signInManager; - private readonly IEmailSender _emailSender; + private readonly IEmailSender<<#= Model.UserClassName #>> _emailSender; public EmailModel( UserManager<<#= Model.UserClassName #>> userManager, SignInManager<<#= Model.UserClassName #>> signInManager, - IEmailSender emailSender) + IEmailSender<<#= Model.UserClassName #>> emailSender) { _userManager = userManager; _signInManager = signInManager; @@ -128,10 +128,7 @@ public class EmailModel : PageModel pageHandler: null, values: new { area = "Identity", userId = userId, email = Input.NewEmail, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - Input.NewEmail, - "Confirm your email", - $"Please confirm your account by clicking here. If you didn't request this email confirmation, you can ignore this email."); + await _emailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl)); StatusMessage = "Confirmation link to change email sent. Please check your email."; return RedirectToPage(); @@ -164,10 +161,7 @@ public class EmailModel : PageModel pageHandler: null, values: new { area = "Identity", userId = userId, code = code }, protocol: Request.Scheme)!; - await _emailSender.SendEmailAsync( - email!, - "Confirm your email", - $"Please confirm your account by clicking here. If you didn't request this email confirmation, you can ignore this email."); + await _emailSender.SendConfirmationLinkAsync(user, email!, HtmlEncoder.Default.Encode(callbackUrl)); StatusMessage = "Verification email sent. Please check your email."; return RedirectToPage(); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.cs index 5c11b54322..5f68e9aae6 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/Manage/_ManageNav.cs @@ -25,10 +25,7 @@ public partial class _ManageNav : _ManageNavBase /// public virtual string TransformText() { - this.Write("@inject SignInManager<"); - this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName)); - this.Write("> SignInManager\r\n@{\r\n var hasExternalLogins = (await SignInManager.GetExternal" + - "AuthenticationSchemesAsync()).Any();\r\n}\r\n
    \r" + + this.Write("
      \r" + "\n
    • Profile
    • \r\n
    • Email
    • \r\n <" + "li class=\"nav-item\">Password\r\n @if (h" + - "asExternalLogins)\r\n {\r\n
    • Password
    • \r\n @if ((" + + "bool)(ViewData[\"ManageNav.HasExternalLogins\"] ?? false))\r\n {\r\n
    • External logins
    • \r\n }\r\n
    • <#@ import namespace="System.Collections.Generic" #> <#@ import namespace="System.Text" #> -<#@ import namespace="System.Linq" #> -@inject SignInManager<<#= Model.UserClassName #>> SignInManager -@{ - var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); -} ".Length; + var insertionIndex = closingListIndex + closingList.Length; layoutContent = layoutContent.Insert(insertionIndex, $"{newline}{indentation}"); fileSystem.WriteAllText(layoutPath, layoutContent); return Task.FromResult(true); } + private static (int Index, int Length) FindMatchingClosingList(string content, int openingListIndex) + { + if (openingListIndex < 0) + { + return (-1, 0); + } + + var depth = 0; + foreach (Match match in Regex.Matches( + content[openingListIndex..], + @"<\s*(/?)\s*ul\b[^>]*>", + RegexOptions.IgnoreCase)) + { + depth += match.Groups[1].Length > 0 ? -1 : 1; + if (depth == 0) + { + return (openingListIndex + match.Index, match.Length); + } + } + + return (-1, 0); + } + private string GetLoginPartialContent() { var returnUrl = IsRazorPages diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs index af1d7de50f..880a65a151 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs @@ -1,5 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -111,6 +112,49 @@ public async Task ExecuteAsync_AddsLoginPartialToNavbarList() Assert.Contains("
    \n \n", writtenFiles[layoutPath]); } + [Fact] + public async Task ExecuteAsync_AddsLoginPartialAfterNavbarWithNestedList() + { + var projectDirectory = Path.Combine("test", "project"); + var projectPath = Path.Combine(projectDirectory, "TestProject.csproj"); + var sharedDirectory = Path.Combine(projectDirectory, "Views", "Shared"); + var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); + var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); + var layoutContent = """ + +"""; + var writtenFiles = new Dictionary(); + var fileSystem = new Mock(); + fileSystem.Setup(fs => fs.FileExists(layoutPath)).Returns(true); + fileSystem.Setup(fs => fs.FileExists(loginPartialPath)).Returns(false); + fileSystem.Setup(fs => fs.ReadAllText(layoutPath)).Returns(layoutContent); + fileSystem.Setup(fs => fs.WriteAllText(It.IsAny(), It.IsAny())) + .Callback((path, content) => writtenFiles[path] = content); + + var step = new ConfigureIdentityNavigationStep( + NullLogger.Instance, + fileSystem.Object) + { + ProjectPath = projectPath, + UserClassName = "ApplicationUser", + UserClassNamespace = "TestProject.Data" + }; + + var result = await step.ExecuteAsync(new ScaffolderContext(Mock.Of())); + + Assert.True(result); + var updatedLayout = writtenFiles[layoutPath].Replace("\r\n", "\n", StringComparison.Ordinal); + Assert.DoesNotContain("
\n \n ", updatedLayout); + Assert.Contains("\n \n \n", updatedLayout); + } + [Fact] public async Task ExecuteAsync_DoesNotCreatePartialWhenNavbarListIsMissing() { From c14fc110b77e083a4285af5e05a049c7d7b3e7a0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Sat, 19 Sep 2026 20:25:30 -0700 Subject: [PATCH 9/9] Simplify Identity scaffolding and verify generated UI Reuse Roslyn and MSBuild project analysis, preserve existing Identity configuration, and generate host navigation through the template pipeline. Fix project-relative output paths, missing appsettings creation, and .NET 11 no-op email-sender detection. Consolidate SDK-pinned MVC and Razor Pages lifecycle coverage, require canonical generated pages, and verify unchanged second scaffolding runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7f8f84a2-809d-43db-b74e-4f4f888dfd4c --- .../Services/MSBuildProjectService.cs | 9 + .../AspNet/AspNetCommandService.cs | 1 + .../AspNet/Common/ClassAnalyzers.cs | 1 + .../AspNet/Common/ProjectInfo.cs | 1 + .../IdentityScaffolderBuilderExtensions.cs | 44 +- .../AspNet/Helpers/IdentityHelper.cs | 68 +- .../AspNet/Models/IdentityModel.cs | 2 + .../AddAspNetConnectionStringStep.cs | 6 +- .../ScaffoldSteps/AddIdentityMigrationStep.cs | 21 +- .../ConfigureIdentityNavigationStep.cs | 124 ++-- .../IdentityCodeModificationStep.cs | 123 ++++ .../ScaffoldSteps/ValidateIdentityStep.cs | 31 +- .../identityChanges.json | 2 +- .../net10.0/Files/_LoginPartial.Interfaces.cs | 11 + .../Templates/net10.0/Files/_LoginPartial.cs | 258 ++++++++ .../Templates/net10.0/Files/_LoginPartial.tt | 29 + .../identityChanges.json | 2 +- .../Account/RegisterConfirmationModel.cs | 2 +- .../Account/RegisterConfirmationModel.tt | 4 +- .../identityChanges.json | 2 +- .../identityChanges.json | Bin 5748 -> 5730 bytes .../dotnet-scaffold/README.md | 14 +- .../dotnet-scaffold/dotnet-scaffold.csproj | 5 + ...dentityScaffolderBuilderExtensionsTests.cs | 4 +- .../AspNet/Helpers/IdentityHelperTests.cs | 95 ++- .../Identity/IdentityEndToEndNet10Tests.cs | 592 ------------------ .../Identity/IdentityEndToEndTests.cs | 386 ++++++++++++ .../Identity/IdentityIntegrationTestsBase.cs | 2 +- .../Identity/IdentityNet11IntegrationTests.cs | 30 +- .../AddAspNetConnectionStringStepTests.cs | 2 +- .../ConfigureIdentityNavigationStepTests.cs | 182 ++---- .../IdentityCodeModificationStepTests.cs | 71 +++ .../Helpers/ScaffoldCliHelper.cs | 44 ++ 33 files changed, 1257 insertions(+), 911 deletions(-) create mode 100644 src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/IdentityCodeModificationStep.cs create mode 100644 src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.Interfaces.cs create mode 100644 src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.cs create mode 100644 src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.tt delete mode 100644 test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs create mode 100644 test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndTests.cs create mode 100644 test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/IdentityCodeModificationStepTests.cs diff --git a/src/dotnet-scaffolding/Microsoft.DotNet.Scaffolding.Roslyn/Services/MSBuildProjectService.cs b/src/dotnet-scaffolding/Microsoft.DotNet.Scaffolding.Roslyn/Services/MSBuildProjectService.cs index 062ebce312..d47864887a 100644 --- a/src/dotnet-scaffolding/Microsoft.DotNet.Scaffolding.Roslyn/Services/MSBuildProjectService.cs +++ b/src/dotnet-scaffolding/Microsoft.DotNet.Scaffolding.Roslyn/Services/MSBuildProjectService.cs @@ -39,6 +39,15 @@ public IEnumerable GetProjectCapabilities(bool refresh = false) return []; } + /// + /// Gets an evaluated project property, including values supplied by imported props and targets. + /// + public string? GetPropertyValue(string propertyName) + { + EnsureInitialized(); + return _project?.GetPropertyValue(propertyName); + } + private void Initialize(bool refresh = false) { lock (_initLock) diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/AspNetCommandService.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/AspNetCommandService.cs index 074ff3f61f..4965c7ff4f 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/AspNetCommandService.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/AspNetCommandService.cs @@ -32,6 +32,7 @@ public Type[] GetScaffoldSteps() typeof(DetectBlazorWasmStep), typeof(DotnetNewScaffolderStep), typeof(EmptyControllerScaffolderStep), + typeof(IdentityCodeModificationStep), typeof(NuGetVersionService), typeof(RegisterAppStep), typeof(UpdateAppAuthorizationStep), diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Common/ClassAnalyzers.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Common/ClassAnalyzers.cs index 7ddd3ae0e2..68b3855de9 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Common/ClassAnalyzers.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Common/ClassAnalyzers.cs @@ -206,6 +206,7 @@ internal static ProjectInfo GetProjectInfo(string projectPath, ILogger logger) ProjectInfo projectInfo = new(projectPath) { CodeService = codeService, + ProjectAssetsFile = msBuildProject.GetPropertyValue("ProjectAssetsFile"), Capabilities = capabilities }; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Common/ProjectInfo.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Common/ProjectInfo.cs index 9b2ffc582a..effada1139 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Common/ProjectInfo.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Common/ProjectInfo.cs @@ -25,6 +25,7 @@ public ProjectInfo(string? projectPath) /// Gets or sets the code service for the project. /// public CodeService? CodeService { get; set; } + public string? ProjectAssetsFile { get; set; } /// /// Gets or sets the list of code change options for the project. /// diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs index 7636dd83f4..4303c8f8e7 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Extensions/IdentityScaffolderBuilderExtensions.cs @@ -87,7 +87,8 @@ public static IScaffoldBuilder WithIdentityTextTemplatingStep(this IScaffoldBuil //all the .cshtml and their model class (.cshtml.cs) templates var allIdentityPageFiles = templateFolderUtilities.GetAllT4TemplatesForTargetFramework(["Identity"], identityModel.ProjectInfo.ProjectPath); //ApplicationUser.tt template - var applicationUserFile = templateFolderUtilities.GetAllT4TemplatesForTargetFramework(["Files"], identityModel.ProjectInfo.ProjectPath) + var fileTemplates = templateFolderUtilities.GetAllT4TemplatesForTargetFramework(["Files"], identityModel.ProjectInfo.ProjectPath).ToList(); + var applicationUserFile = fileTemplates .FirstOrDefault(x => x.EndsWith("ApplicationUser.tt", StringComparison.OrdinalIgnoreCase)); var identityFileProperties = IdentityHelper.GetTextTemplatingProperties(allIdentityPageFiles, identityModel); var applicationUserProperty = IdentityHelper.GetApplicationUserTextTemplatingProperty(applicationUserFile, identityModel); @@ -95,6 +96,12 @@ public static IScaffoldBuilder WithIdentityTextTemplatingStep(this IScaffoldBuil { identityFileProperties = identityFileProperties.Append(applicationUserProperty); } + var loginPartialTemplate = fileTemplates.FirstOrDefault(x => x.EndsWith("_LoginPartial.tt", StringComparison.OrdinalIgnoreCase)); + var loginPartialProperty = IdentityHelper.GetLoginPartialTextTemplatingProperty(loginPartialTemplate, identityModel); + if (loginPartialProperty is not null) + { + identityFileProperties = identityFileProperties.Append(loginPartialProperty); + } if (identityFileProperties is not null && identityFileProperties.Any()) { @@ -119,7 +126,7 @@ public static IScaffoldBuilder WithIdentityTextTemplatingStep(this IScaffoldBuil /// The updated scaffold builder. public static IScaffoldBuilder WithIdentityCodeChangeStep(this IScaffoldBuilder builder) { - builder = builder.WithStep(config => + builder = builder.WithStep(config => { var step = config.Step; //get needed properties and cast them as needed @@ -143,8 +150,7 @@ codeModifierProperties is not null && { step.CodeModifierProperties.TryAdd(kvp.Key, kvp.Value); } - step.CodeModifierProperties["$(IdentityRegistrationCheck)"] = GetIdentityRegistrationCheck(identitySettings.Project); - + step.CodeService = identityModel.ProjectInfo.CodeService!; step.ProjectPath = identitySettings.Project; step.CodeChangeOptions = identityModel.ProjectInfo.CodeChangeOptions ?? []; } @@ -158,32 +164,6 @@ codeModifierProperties is not null && return builder; } - private static string GetIdentityRegistrationCheck(string projectPath) - { - var projectDirectory = Path.GetDirectoryName(projectPath); - var programPath = string.IsNullOrEmpty(projectDirectory) ? null : Path.Combine(projectDirectory, "Program.cs"); - if (programPath is null || !File.Exists(programPath)) - { - return "__IdentityRegistrationNotFound__"; - } - - var programContent = File.ReadAllText(programPath); - foreach (var registration in new[] - { - "builder.Services.AddDefaultIdentity", - "builder.Services.AddIdentityCore", - "builder.Services.AddIdentity" - }) - { - if (programContent.Contains(registration, StringComparison.Ordinal)) - { - return registration; - } - } - - return "__IdentityRegistrationNotFound__"; - } - /// /// Adds a step to configure Identity navigation in the host application's layout. /// @@ -199,8 +179,6 @@ public static IScaffoldBuilder WithIdentityNavigationStep(this IScaffoldBuilder { step.ProjectPath = identityModel.ProjectInfo.ProjectPath ?? string.Empty; step.IsRazorPages = identityModel.IsRazorPages; - step.UserClassName = identityModel.UserClassName; - step.UserClassNamespace = identityModel.UserClassNamespace; } else { @@ -224,6 +202,8 @@ public static IScaffoldBuilder WithIdentityMigrationStep(this IScaffoldBuilder b { step.ProjectPath = identityModel.ProjectInfo.ProjectPath ?? string.Empty; step.DbContextName = identityModel.DbContextInfo.DbContextClassName ?? string.Empty; + step.ProjectAssetsFile = identityModel.ProjectInfo.ProjectAssetsFile ?? string.Empty; + step.SkipStep = identityModel.HasMigration; } else { diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/IdentityHelper.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/IdentityHelper.cs index 1a3188c9e4..5ec47b8732 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/IdentityHelper.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Helpers/IdentityHelper.cs @@ -1,8 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.DotNet.Scaffolding.Core.Model; using Microsoft.DotNet.Scaffolding.Internal; using Microsoft.DotNet.Scaffolding.TextTemplating; +using Microsoft.DotNet.Tools.Scaffold.AspNet.Common; using Microsoft.DotNet.Tools.Scaffold.AspNet.Models; namespace Microsoft.DotNet.Tools.Scaffold.AspNet.Helpers; @@ -12,6 +15,11 @@ namespace Microsoft.DotNet.Tools.Scaffold.AspNet.Helpers; /// internal static class IdentityHelper { + internal static InvocationExpressionSyntax? FindIdentityRegistration(SyntaxNode root) + => root.DescendantNodes().OfType().FirstOrDefault(call => + call.Expression is MemberAccessExpressionSyntax { Name: GenericNameSyntax name } && + name.Identifier.ValueText is "AddDefaultIdentity" or "AddIdentity" or "AddIdentityCore"); + /// /// Use the template paths and IdentityModel to create valid 'TextTemplateProperty' objects. /// @@ -36,8 +44,7 @@ internal static IEnumerable GetTextTemplatingProperties( x.FullName.Contains(templateFullName) && x.Name.Equals(typeName, StringComparison.OrdinalIgnoreCase)); - var projectName = Path.GetFileNameWithoutExtension(identityModel.ProjectInfo.ProjectPath); - if (!string.IsNullOrEmpty(templatePath) && templateType is not null && !string.IsNullOrEmpty(projectName)) + if (!string.IsNullOrEmpty(templatePath) && templateType is not null) { string extension = string.Empty; //the 'ManageNavPagesModel.tt' only should have .cs extension (only exception) @@ -50,9 +57,11 @@ internal static IEnumerable GetTextTemplatingProperties( extension = templateFullName.EndsWith("Model", StringComparison.OrdinalIgnoreCase) ? ".cshtml.cs" : ".cshtml"; } - string formattedTemplateName = templateFullName.Replace("Model", string.Empty, StringComparison.OrdinalIgnoreCase); - string templateNameWithNamespace = $"{identityModel.IdentityNamespace}.{formattedTemplateName}"; - string outputFileName = $"{StringUtil.ToPath(templateNameWithNamespace, identityModel.BaseOutputPath, projectName)}{extension}"; + var name = templateFullName.EndsWith("Model", StringComparison.OrdinalIgnoreCase) + ? templateFullName[..^"Model".Length] + : templateFullName; + var outputFileName = Path.Combine(identityModel.BaseOutputPath, "Areas", "Identity", + name.Replace('.', Path.DirectorySeparatorChar)) + extension; textTemplatingProperties.Add(new() { TemplateModel = identityModel, @@ -75,7 +84,7 @@ internal static IEnumerable GetTextTemplatingProperties( private static string GetFormattedRelativeIdentityFile(string fullFileName) { string identifier = $"Identity{Path.DirectorySeparatorChar}"; - int index = fullFileName.IndexOf(identifier); + int index = fullFileName.LastIndexOf(identifier, StringComparison.Ordinal); if (index != -1) { string pathAfterIdentifier = fullFileName.Substring(index + identifier.Length); @@ -95,7 +104,7 @@ private static string GetFormattedRelativeIdentityFile(string fullFileName) internal static TextTemplatingProperty? GetApplicationUserTextTemplatingProperty(string? applicationUserTemplate, IdentityModel identityModel) { var projectDirectory = Path.GetDirectoryName(identityModel.ProjectInfo.ProjectPath); - if (string.IsNullOrEmpty(applicationUserTemplate) || string.IsNullOrEmpty(projectDirectory)) + if (identityModel.HasExistingUser || string.IsNullOrEmpty(applicationUserTemplate) || string.IsNullOrEmpty(projectDirectory)) { return null; } @@ -117,6 +126,51 @@ private static string GetFormattedRelativeIdentityFile(string fullFileName) }; } + internal static TextTemplatingProperty? GetLoginPartialTextTemplatingProperty(string? templatePath, IdentityModel model) + { + var projectDirectory = Path.GetDirectoryName(model.ProjectInfo.ProjectPath); + if (string.IsNullOrEmpty(templatePath) || string.IsNullOrEmpty(projectDirectory)) + { + return null; + } + + var outputPath = Path.Combine(projectDirectory, model.IsRazorPages ? "Pages" : "Views", "Shared", "_LoginPartial.cshtml"); + if (File.Exists(outputPath)) + { + return null; + } + + return new TextTemplatingProperty + { + TemplatePath = templatePath, + TemplateType = typeof(Templates.net10.Files._LoginPartial), + TemplateModel = model, + TemplateModelName = "Model", + OutputPath = outputPath + }; + } + + internal static bool HasMigration(IEnumerable classes, DbContextInfo context) + => classes.OfType().Any(type => + type.BaseType?.Name == "ModelSnapshot" && + type.GetAttributes().Any(attribute => + attribute.AttributeClass?.Name == "DbContextAttribute" && + attribute.ConstructorArguments.FirstOrDefault().Value is INamedTypeSymbol dbContext && + dbContext.Name == context.DbContextClassName && + (dbContext.ContainingNamespace.IsGlobalNamespace ? string.Empty : dbContext.ContainingNamespace.ToDisplayString()) == context.DbContextNamespace)); + + internal static ITypeSymbol? GetIdentityUserType(INamedTypeSymbol? dbContext) + { + for (var type = dbContext; type is not null; type = type.BaseType) + { + if (type.Name is "IdentityDbContext" or "IdentityUserContext" && type.TypeArguments.Length > 0) + { + return type.TypeArguments[0]; + } + } + return null; + } + private static IList GetIdentityTemplateTypes(TargetFramework? targetFramework) { return targetFramework switch diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Models/IdentityModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Models/IdentityModel.cs index 6410d9e43b..d302a35fa9 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Models/IdentityModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Models/IdentityModel.cs @@ -50,4 +50,6 @@ internal class IdentityModel /// Used to determine the correct layout path in _ViewStart.cshtml. /// public bool IsRazorPages { get; set; } + public bool HasMigration { get; set; } + public bool HasExistingUser { get; set; } } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddAspNetConnectionStringStep.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddAspNetConnectionStringStep.cs index a82e23f5b6..1ef0e1ef9e 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddAspNetConnectionStringStep.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddAspNetConnectionStringStep.cs @@ -48,10 +48,10 @@ public AddAspNetConnectionStringStep( public override Task ExecuteAsync(ScaffolderContext context, CancellationToken cancellationToken = default) { var appSettingsFileSearch = _fileSystem.EnumerateFiles(BaseProjectPath, "appsettings.json", SearchOption.AllDirectories); - var appSettingsFile = appSettingsFileSearch.FirstOrDefault(); + var appSettingsFile = appSettingsFileSearch.FirstOrDefault() ?? Path.Combine(BaseProjectPath, "appsettings.json"); JsonNode? content; bool writeContent = false; - if (string.IsNullOrEmpty(appSettingsFile) || !_fileSystem.FileExists(appSettingsFile)) + if (!_fileSystem.FileExists(appSettingsFile)) { content = new JsonObject(); writeContent = true; @@ -97,7 +97,7 @@ connectionStringObject[ConnectionStringName] is null && content[connectionStringNodeName] = connectionStringObject; } - if (writeContent && !string.IsNullOrEmpty(appSettingsFile)) + if (writeContent) { var options = new JsonSerializerOptions { WriteIndented = true }; _fileSystem.WriteAllText(appSettingsFile, content.ToJsonString(options)); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddIdentityMigrationStep.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddIdentityMigrationStep.cs index 816dff9fcd..efe99fffe1 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddIdentityMigrationStep.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/AddIdentityMigrationStep.cs @@ -18,6 +18,7 @@ internal class AddIdentityMigrationStep( { public required string ProjectPath { get; set; } public required string DbContextName { get; set; } + public required string ProjectAssetsFile { get; set; } public override Task ExecuteAsync(ScaffolderContext context, CancellationToken cancellationToken = default) { @@ -28,19 +29,13 @@ public override Task ExecuteAsync(ScaffolderContext context, CancellationT return Task.FromResult(false); } - if (HasMigration(projectDirectory)) + if (string.IsNullOrEmpty(ProjectAssetsFile) || !fileSystem.FileExists(ProjectAssetsFile)) { - return Task.FromResult(true); - } - - var assetsPath = Path.Combine(projectDirectory, "obj", "project.assets.json"); - if (!fileSystem.FileExists(assetsPath)) - { - logger.LogError($"Unable to generate the Identity migration because '{assetsPath}' does not exist."); + logger.LogError($"Unable to generate the Identity migration because the project's assets file '{ProjectAssetsFile}' does not exist."); return Task.FromResult(false); } - var efVersion = GetEfDesignPackageVersion(fileSystem.ReadAllText(assetsPath)); + var efVersion = GetEfDesignPackageVersion(fileSystem.ReadAllText(ProjectAssetsFile)); if (string.IsNullOrEmpty(efVersion)) { logger.LogError("Unable to determine the Microsoft.EntityFrameworkCore.Design package version."); @@ -64,7 +59,7 @@ public override Task ExecuteAsync(ScaffolderContext context, CancellationT { Directory.Delete(toolDirectory, recursive: true); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { logger.LogWarning($"Unable to remove temporary EF Core tooling directory '{toolDirectory}': {ex.Message}"); } @@ -91,12 +86,6 @@ public override Task ExecuteAsync(ScaffolderContext context, CancellationT return null; } - private bool HasMigration(string projectDirectory) - { - return fileSystem.EnumerateFiles(projectDirectory, "*ModelSnapshot.cs", SearchOption.AllDirectories) - .Any(path => fileSystem.ReadAllText(path).Contains(DbContextName, StringComparison.Ordinal)); - } - private bool InstallEfTool(string toolDirectory, string projectDirectory, string version) { logger.LogInformation("Installing temporary EF Core tooling..."); diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs index 8b847a2187..9d73a251be 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStep.cs @@ -8,16 +8,11 @@ namespace Microsoft.DotNet.Tools.Scaffold.AspNet.ScaffoldSteps; -/// -/// Adds the Identity login partial and references it from the host application's layout. -/// internal class ConfigureIdentityNavigationStep( ILogger logger, IFileSystem fileSystem) : ScaffoldStep { public required string ProjectPath { get; set; } - public required string UserClassName { get; set; } - public required string UserClassNamespace { get; set; } public bool IsRazorPages { get; set; } public override Task ExecuteAsync(ScaffolderContext context, CancellationToken cancellationToken = default) @@ -29,112 +24,65 @@ public override Task ExecuteAsync(ScaffolderContext context, CancellationT return Task.FromResult(false); } - var sharedDirectory = Path.Combine(projectDirectory, IsRazorPages ? "Pages" : "Views", "Shared"); - var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); + var layoutPath = Path.Combine(projectDirectory, IsRazorPages ? "Pages" : "Views", "Shared", "_Layout.cshtml"); if (!fileSystem.FileExists(layoutPath)) { - logger.LogWarning($"Identity navigation was not added because '{layoutPath}' does not exist."); + logger.LogWarning($"Add the '_LoginPartial' partial to your layout; '{layoutPath}' does not exist."); return Task.FromResult(true); } - var layoutContent = fileSystem.ReadAllText(layoutPath); - if (layoutContent.Contains("_LoginPartial", StringComparison.OrdinalIgnoreCase)) + var original = fileSystem.ReadAllText(layoutPath); + var updated = AddLoginPartialReference(original); + if (updated is null) { - return Task.FromResult(true); + logger.LogWarning($"Add the '_LoginPartial' partial to '{layoutPath}'; no navbar navigation list was found."); } - - var navbarClassIndex = layoutContent.IndexOf("navbar-nav", StringComparison.OrdinalIgnoreCase); - var openingListIndex = navbarClassIndex < 0 - ? -1 - : layoutContent.LastIndexOf(" !char.IsWhiteSpace(character))) - { - indentation = string.Empty; - } - - var newline = layoutContent.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; - var insertionIndex = closingListIndex + closingList.Length; - layoutContent = layoutContent.Insert(insertionIndex, $"{newline}{indentation}"); - fileSystem.WriteAllText(layoutPath, layoutContent); - return Task.FromResult(true); } - private static (int Index, int Length) FindMatchingClosingList(string content, int openingListIndex) + internal static string? AddLoginPartialReference(string content) { - if (openingListIndex < 0) + // Ignore Razor/HTML comments without changing offsets into the original layout. + var markup = Regex.Replace(content, @"@\*[\s\S]*?\*@|", + match => new string(' ', match.Length)); + if (Regex.IsMatch(markup, @"]*\bname\s*=\s*(['""])_LoginPartial\1|(?:Partial|RenderPartial)(?:Async)?\(\s*""_LoginPartial""", + RegexOptions.IgnoreCase)) { - return (-1, 0); + return content; } var depth = 0; - foreach (Match match in Regex.Matches( - content[openingListIndex..], - @"<\s*(/?)\s*ul\b[^>]*>", - RegexOptions.IgnoreCase)) + foreach (Match tag in Regex.Matches(markup, @"<\s*(/?)\s*ul\b[^>]*>", RegexOptions.IgnoreCase)) { - depth += match.Groups[1].Length > 0 ? -1 : 1; if (depth == 0) { - return (openingListIndex + match.Index, match.Length); + var attribute = Regex.Match(tag.Value, @"\bclass\s*=\s*(['""])(.*?)\1", RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (tag.Groups[1].Length != 0 || + !attribute.Groups[2].Value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Contains("navbar-nav", StringComparer.OrdinalIgnoreCase)) + { + continue; + } } - } - return (-1, 0); - } - - private string GetLoginPartialContent() - { - var returnUrl = IsRazorPages - ? "@Url.Page(\"/Index\", new { area = \"\" })" - : "@Url.Action(\"Index\", \"Home\", new { area = \"\" })"; - - return $$""" -@using Microsoft.AspNetCore.Identity -@using {{UserClassNamespace}} -@inject SignInManager<{{UserClassName}}> SignInManager -@inject UserManager<{{UserClassName}}> UserManager + depth += tag.Groups[1].Length == 0 ? 1 : -1; + if (depth == 0) + { + var lineStart = content.LastIndexOf('\n', tag.Index) + 1; + var indentation = content[lineStart..tag.Index]; + if (indentation.Any(character => !char.IsWhiteSpace(character))) + { + indentation = string.Empty; + } + var newline = content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + return content.Insert(tag.Index + tag.Length, $"{newline}{indentation}"); + } + } - -"""; + return null; } } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/IdentityCodeModificationStep.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/IdentityCodeModificationStep.cs new file mode 100644 index 0000000000..055b8d7600 --- /dev/null +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/IdentityCodeModificationStep.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.DotNet.Scaffolding.Core.Scaffolders; +using Microsoft.DotNet.Scaffolding.Internal.Services; +using Microsoft.DotNet.Scaffolding.Roslyn.Services; +using Microsoft.DotNet.Tools.Scaffold.AspNet.Helpers; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Tools.Scaffold.AspNet.ScaffoldSteps; + +internal class IdentityCodeModificationStep( + ILogger logger, + ITelemetryService telemetryService) : WrappedCodeModificationStep(logger, telemetryService) +{ + public required ICodeService CodeService { get; set; } + + public override async Task ExecuteAsync(ScaffolderContext context, CancellationToken cancellationToken = default) + { + var document = await CodeService.GetDocumentAsync("Program.cs"); + var root = document is null ? null : await document.GetSyntaxRootAsync(cancellationToken); + var config = JsonNode.Parse(File.ReadAllText(CodeModifierConfigPath!)); + if (root is null || config?["Files"]?[0]?["Methods"]?["Global"]?["CodeChanges"] is not JsonArray changes) + { + logger.LogError("Unable to read Program.cs or the Identity code modification configuration."); + return false; + } + + var registration = IdentityHelper.FindIdentityRegistration(root); + if (registration is null) + { + CodeChangeOptions.Add("AddDefaultIdentity"); + } + else + { + CodeChangeOptions.Remove("AddDefaultIdentity"); + foreach (var change in GetMissingIdentityChanges(root, registration)) + { + changes.Add(JsonSerializer.SerializeToNode(change)); + } + } + + CodeModifierConfigJsonText = config.ToJsonString(); + return await base.ExecuteAsync(context, cancellationToken); + } + + internal static IEnumerable GetMissingIdentityChanges(SyntaxNode root, InvocationExpressionSyntax registration) + { + var calls = root.DescendantNodes().OfType().ToList(); + var names = calls.Select(GetMethodName).ToHashSet(StringComparer.Ordinal); + var parent = registration.ToString(); + + if (!names.Contains("AddEntityFrameworkStores") && !names.Contains("AddUserStore")) + { + yield return new + { + Parent = parent, + Block = "AddEntityFrameworkStores<$(DbContextName)>()", + CodeChangeType = "MemberAccess" + }; + } + + if (GetMethodName(registration) == "AddDefaultIdentity") + { + yield break; + } + + if (!names.Contains("AddDefaultUI")) + { + yield return new { Parent = parent, Block = "AddDefaultUI()", CodeChangeType = "MemberAccess" }; + } + if (!names.Contains("AddDefaultTokenProviders") && !names.Contains("AddTokenProvider")) + { + yield return new { Parent = parent, Block = "AddDefaultTokenProviders()", CodeChangeType = "MemberAccess" }; + } + + if (GetMethodName(registration) != "AddIdentityCore" || names.Contains("AddIdentityCookies")) + { + yield break; + } + + var services = ((MemberAccessExpressionSyntax)registration.Expression).Expression.ToString(); + yield return new + { + InsertBefore = new[] { "builder.Build()", "WebApplication.CreateBuilder.Build()" }, + Block = $$""" +{{services}}.AddAuthentication(options => +{ + options.DefaultScheme ??= IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme ??= IdentityConstants.ExternalScheme; +}) +""", + }; + + foreach (var (method, scheme) in new[] + { + ("AddApplicationCookie", "Application"), + ("AddExternalCookie", "External"), + ("AddTwoFactorRememberMeCookie", "TwoFactorRememberMe"), + ("AddTwoFactorUserIdCookie", "TwoFactorUserId") + }) + { + var hasCookie = names.Contains(method) || calls.Any(call => + GetMethodName(call) == "AddCookie" && + call.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString() is { } argument && + (argument == $"IdentityConstants.{scheme}Scheme" || argument == $"\"Identity.{scheme}\"")); + if (!hasCookie) + { + yield return new + { + InsertBefore = new[] { "builder.Build()", "WebApplication.CreateBuilder.Build()" }, + Block = $"{services}.AddAuthentication().{method}()" + }; + } + } + } + + private static string? GetMethodName(InvocationExpressionSyntax call) + => call.Expression is MemberAccessExpressionSyntax member ? member.Name.Identifier.ValueText : null; +} diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ValidateIdentityStep.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ValidateIdentityStep.cs index 26dd282b77..b4a7191c9f 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ValidateIdentityStep.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/ScaffoldSteps/ValidateIdentityStep.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.DotNet.Scaffolding.Core.Scaffolders; using Microsoft.DotNet.Scaffolding.Core.Steps; using Microsoft.DotNet.Scaffolding.Internal.Services; @@ -14,6 +16,7 @@ using Microsoft.Extensions.Logging; using AspNetConstants = Microsoft.DotNet.Tools.Scaffold.AspNet.Common.Constants; using Constants = Microsoft.DotNet.Scaffolding.Internal.Constants; +using ProjectInfo = Microsoft.DotNet.Tools.Scaffold.AspNet.Common.ProjectInfo; namespace Microsoft.DotNet.Tools.Scaffold.AspNet.ScaffoldSteps; @@ -169,7 +172,7 @@ public override async Task ExecuteAsync(ScaffolderContext context, Cancell return new IdentitySettings { - Project = Project, + Project = Path.GetFullPath(Project), DataContext = DataContext, DatabaseProvider = DatabaseProvider, Prerelease = Prerelease, @@ -240,6 +243,32 @@ public override async Task ExecuteAsync(ScaffolderContext context, Cancell IsRazorPages = isRazorPages }; + if (!settings.BlazorScenario) + { + scaffoldingModel.HasMigration = IdentityHelper.HasMigration(allClasses, dbContextInfo); + var existingContext = allClasses.OfType().FirstOrDefault(type => + type.Name == dbContextInfo.DbContextClassName && + type.ContainingNamespace.ToDisplayString() == dbContextInfo.DbContextNamespace); + var userType = IdentityHelper.GetIdentityUserType(existingContext); + if (userType is null or { TypeKind: TypeKind.Error }) + { + var program = await projectInfo.CodeService.GetDocumentAsync("Program.cs"); + if (program is not null && + await program.GetSyntaxRootAsync() is { } root && + IdentityHelper.FindIdentityRegistration(root)?.Expression is MemberAccessExpressionSyntax { Name: GenericNameSyntax name } && + await program.GetSemanticModelAsync() is { } semanticModel) + { + userType = semanticModel.GetTypeInfo(name.TypeArgumentList.Arguments[0]).Type; + } + } + if (userType is { TypeKind: TypeKind.Class }) + { + scaffoldingModel.UserClassName = userType.Name; + scaffoldingModel.UserClassNamespace = userType.ContainingNamespace.ToDisplayString(); + scaffoldingModel.HasExistingUser = true; + } + } + if (scaffoldingModel.ProjectInfo is not null && scaffoldingModel.ProjectInfo.CodeService is not null) { scaffoldingModel.ProjectInfo.CodeChangeOptions = diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json index bc2ae14e48..e0172576f9 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/CodeModificationConfigs/identityChanges.json @@ -28,7 +28,7 @@ }, { "InsertAfter": "builder.Services.AddDbContext", - "CheckBlock": "$(IdentityRegistrationCheck)", + "Options": [ "AddDefaultIdentity" ], "Block": "builder.Services.AddDefaultIdentity<$(UserClassName)>(options => options.SignIn.RequireConfirmedAccount = true).AddEntityFrameworkStores<$(DbContextName)>()", "LeadingTrivia": { "Newline": true diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.Interfaces.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.Interfaces.cs new file mode 100644 index 0000000000..fa77220a99 --- /dev/null +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.Interfaces.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.DotNet.Scaffolding.TextTemplating; +using System.CodeDom.Compiler; + +namespace Microsoft.DotNet.Tools.Scaffold.AspNet.Templates.net10.Files; + +internal partial class _LoginPartial : ITextTransformation +{ + CompilerErrorCollection ITextTransformation.Errors => Errors; +} diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.cs new file mode 100644 index 0000000000..d0ab59816d --- /dev/null +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.cs @@ -0,0 +1,258 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.DotNet.Tools.Scaffold.AspNet.Templates.net10.Files { + using System; + + + internal partial class _LoginPartial : _LoginPartialBase { + + private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; + + + private Microsoft.DotNet.Tools.Scaffold.AspNet.Models.IdentityModel _ModelField; + + public Microsoft.DotNet.Tools.Scaffold.AspNet.Models.IdentityModel Model { + get { + return this._ModelField; + } + } + + + public global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost Host { + get { + return this.hostValue; + } + set { + this.hostValue = value; + } + } + + public virtual string TransformText() { + this.GenerationEnvironment = null; + this.Write("@using Microsoft.AspNetCore.Identity\r\n@using "); + this.Write(this.ToStringHelper.ToStringWithCulture( Model.UserClassNamespace )); + this.Write("\r\n@inject SignInManager<"); + this.Write(this.ToStringHelper.ToStringWithCulture( Model.UserClassName )); + this.Write("> SignInManager\r\n@inject UserManager<"); + this.Write(this.ToStringHelper.ToStringWithCulture( Model.UserClassName )); + this.Write(@"> UserManager + + +"); + return this.GenerationEnvironment.ToString(); + } + + public virtual void Initialize() { + if ((this.Errors.HasErrors == false)) { + bool _ModelAcquired = false; + if (((this.Session != null) + && this.Session.ContainsKey("Model"))) { + object data = this.Session["Model"]; + if (typeof(Microsoft.DotNet.Tools.Scaffold.AspNet.Models.IdentityModel).IsAssignableFrom(data.GetType())) { + this._ModelField = ((Microsoft.DotNet.Tools.Scaffold.AspNet.Models.IdentityModel)(data)); + _ModelAcquired = true; + } + else { + this.Error(("The type \'Microsoft.DotNet.Tools.Scaffold.AspNet.Models.IdentityModel\' of the par" + + "ameter \'Model\' did not match the type passed to the template")); + } + } + if (((_ModelAcquired == false) + && (this.Host != null))) { + string data = ((global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost)(this.Host)).ResolveParameterValue(null, null, "Model"); + if ((data != null)) { + global::System.ComponentModel.TypeConverter dataTypeConverter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(Microsoft.DotNet.Tools.Scaffold.AspNet.Models.IdentityModel)); + if (((dataTypeConverter != null) + && dataTypeConverter.CanConvertFrom(typeof(string)))) { + this._ModelField = ((Microsoft.DotNet.Tools.Scaffold.AspNet.Models.IdentityModel)(dataTypeConverter.ConvertFromString(data))); + } + else { + this.Error(("The host parameter \'Model\' could not be converted to the type \'System.CodeDom.Cod" + + "eTypeReference\' specified in the template")); + } + } + } + } + + } + } + + public class _LoginPartialBase { + + private global::System.Text.StringBuilder builder; + + private global::System.Collections.Generic.IDictionary session; + + private global::System.CodeDom.Compiler.CompilerErrorCollection errors; + + private string currentIndent = string.Empty; + + private global::System.Collections.Generic.Stack indents; + + private ToStringInstanceHelper _toStringHelper = new ToStringInstanceHelper(); + + public virtual global::System.Collections.Generic.IDictionary Session { + get { + return this.session; + } + set { + this.session = value; + } + } + + public global::System.Text.StringBuilder GenerationEnvironment { + get { + if ((this.builder == null)) { + this.builder = new global::System.Text.StringBuilder(); + } + return this.builder; + } + set { + this.builder = value; + } + } + + protected global::System.CodeDom.Compiler.CompilerErrorCollection Errors { + get { + if ((this.errors == null)) { + this.errors = new global::System.CodeDom.Compiler.CompilerErrorCollection(); + } + return this.errors; + } + } + + public string CurrentIndent { + get { + return this.currentIndent; + } + } + + private global::System.Collections.Generic.Stack Indents { + get { + if ((this.indents == null)) { + this.indents = new global::System.Collections.Generic.Stack(); + } + return this.indents; + } + } + + public ToStringInstanceHelper ToStringHelper { + get { + return this._toStringHelper; + } + } + + public void Error(string message) { + this.Errors.Add(new global::System.CodeDom.Compiler.CompilerError(null, -1, -1, null, message)); + } + + public void Warning(string message) { + global::System.CodeDom.Compiler.CompilerError val = new global::System.CodeDom.Compiler.CompilerError(null, -1, -1, null, message); + val.IsWarning = true; + this.Errors.Add(val); + } + + public string PopIndent() { + if ((this.Indents.Count == 0)) { + return string.Empty; + } + int lastPos = (this.currentIndent.Length - this.Indents.Pop()); + string last = this.currentIndent.Substring(lastPos); + this.currentIndent = this.currentIndent.Substring(0, lastPos); + return last; + } + + public void PushIndent(string indent) { + this.Indents.Push(indent.Length); + this.currentIndent = (this.currentIndent + indent); + } + + public void ClearIndent() { + this.currentIndent = string.Empty; + this.Indents.Clear(); + } + + public void Write(string textToAppend) { + this.GenerationEnvironment.Append(textToAppend); + } + + public void Write(string format, params object[] args) { + this.GenerationEnvironment.AppendFormat(format, args); + } + + public void WriteLine(string textToAppend) { + this.GenerationEnvironment.Append(this.currentIndent); + this.GenerationEnvironment.AppendLine(textToAppend); + } + + public void WriteLine(string format, params object[] args) { + this.GenerationEnvironment.Append(this.currentIndent); + this.GenerationEnvironment.AppendFormat(format, args); + this.GenerationEnvironment.AppendLine(); + } + + public class ToStringInstanceHelper { + + private global::System.IFormatProvider formatProvider = global::System.Globalization.CultureInfo.InvariantCulture; + + public global::System.IFormatProvider FormatProvider { + get { + return this.formatProvider; + } + set { + if ((value != null)) { + this.formatProvider = value; + } + } + } + + public string ToStringWithCulture(object objectToConvert) { + if ((objectToConvert == null)) { + throw new global::System.ArgumentNullException("objectToConvert"); + } + global::System.Type type = objectToConvert.GetType(); + global::System.Type iConvertibleType = typeof(global::System.IConvertible); + if (iConvertibleType.IsAssignableFrom(type)) { + return ((global::System.IConvertible)(objectToConvert)).ToString(this.formatProvider); + } + global::System.Reflection.MethodInfo methInfo = type.GetMethod("ToString", new global::System.Type[] { + iConvertibleType}); + if ((methInfo != null)) { + return ((string)(methInfo.Invoke(objectToConvert, new object[] { + this.formatProvider}))); + } + return objectToConvert.ToString(); + } + } + } +} diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.tt new file mode 100644 index 0000000000..f9515ba056 --- /dev/null +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net10.0/Files/_LoginPartial.tt @@ -0,0 +1,29 @@ +<#@ template hostSpecific="true" linePragmas="false" visibility="internal" #> +<#@ parameter type="Microsoft.DotNet.Tools.Scaffold.AspNet.Models.IdentityModel" name="Model" #> +@using Microsoft.AspNetCore.Identity +@using <#= Model.UserClassNamespace #> +@inject SignInManager<<#= Model.UserClassName #>> SignInManager +@inject UserManager<<#= Model.UserClassName #>> UserManager + + diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json index bc2ae14e48..e0172576f9 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/CodeModificationConfigs/identityChanges.json @@ -28,7 +28,7 @@ }, { "InsertAfter": "builder.Services.AddDbContext", - "CheckBlock": "$(IdentityRegistrationCheck)", + "Options": [ "AddDefaultIdentity" ], "Block": "builder.Services.AddDefaultIdentity<$(UserClassName)>(options => options.SignIn.RequireConfirmedAccount = true).AddEntityFrameworkStores<$(DbContextName)>()", "LeadingTrivia": { "Newline": true diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.cs b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.cs index 0d4b3386c7..cd94e58f37 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.cs +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.cs @@ -76,7 +76,7 @@ public virtual string TransformText() "firmationUrl = Url.Page(\r\n \"/Account/ConfirmEmail\",\r\n " + " pageHandler: null,\r\n values: new { area = \"Identity\", userId " + "= userId, code = code, returnUrl = returnUrl },\r\n protocol: Reque" + - "st.Scheme);\r\n }\r\n\r\n return Page();\r\n }\r\n\r\n private bool IsNoOpEmailSender()\r\n {\r\n // The default typed adapter is internal, so identify it by its stable framework type name.\r\n var senderType = _sender.GetType();\r\n return senderType.IsGenericType\r\n && senderType.GetGenericTypeDefinition().FullName == \"Microsoft.AspNetCore.Identity.UI.Services.DefaultMessageEmailSender`1\"\r\n && _legacySender is NoOpEmailSender;\r\n }\r\n}\r\n"); + "st.Scheme);\r\n }\r\n\r\n return Page();\r\n }\r\n\r\n private bool IsNoOpEmailSender()\r\n {\r\n // The framework's typed adapter is internal, so inspect its type name and legacy sender.\r\n var senderType = _sender.GetType();\r\n return senderType.IsGenericType\r\n && senderType.GetGenericTypeDefinition().FullName == \"Microsoft.AspNetCore.Identity.DefaultMessageEmailSender`1\"\r\n && _legacySender is NoOpEmailSender;\r\n }\r\n}\r\n"); return this.GenerationEnvironment.ToString(); } private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue; diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.tt b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.tt index 8e6af62881..486b63fc26 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.tt +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net11.0/Identity/Pages/Account/RegisterConfirmationModel.tt @@ -88,10 +88,10 @@ public class RegisterConfirmationModel : PageModel private bool IsNoOpEmailSender() { - // The default typed adapter is internal, so identify it by its stable framework type name. + // The framework's typed adapter is internal, so inspect its type name and legacy sender. var senderType = _sender.GetType(); return senderType.IsGenericType - && senderType.GetGenericTypeDefinition().FullName == "Microsoft.AspNetCore.Identity.UI.Services.DefaultMessageEmailSender`1" + && senderType.GetGenericTypeDefinition().FullName == "Microsoft.AspNetCore.Identity.DefaultMessageEmailSender`1" && _legacySender is NoOpEmailSender; } } diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net8.0/CodeModificationConfigs/identityChanges.json b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net8.0/CodeModificationConfigs/identityChanges.json index 654539e57f..0c3f6a8ca0 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net8.0/CodeModificationConfigs/identityChanges.json +++ b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net8.0/CodeModificationConfigs/identityChanges.json @@ -28,7 +28,7 @@ }, { "InsertAfter": "builder.Services.AddDbContext", - "CheckBlock": "$(IdentityRegistrationCheck)", + "Options": [ "AddDefaultIdentity" ], "Block": "builder.Services.AddDefaultIdentity<$(UserClassName)>(options => options.SignIn.RequireConfirmedAccount = true).AddEntityFrameworkStores<$(DbContextName)>()", "LeadingTrivia": { "Newline": true diff --git a/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net9.0/CodeModificationConfigs/identityChanges.json b/src/dotnet-scaffolding/dotnet-scaffold/AspNet/Templates/net9.0/CodeModificationConfigs/identityChanges.json index baad5171df2f97f34a39e80daa41dc90401939b3..c60bf29fbecbc41457ba720502b72af233dcb7e0 100644 GIT binary patch delta 32 ocmeyO^GIid0_WsqjAG0R3`&#Fa;i>t64jdgfX!y}HO_fF0K6~@FaQ7m delta 78 zcmaE)^F?Qa0_Wrfd@`mg3>plc3@Hq$40#MC44FV&$q)qOr32Z;Kwc3;B3L{hDDTXW Y0hCE*$euirS99_MHk-{yIOp*I01jLfLI3~& diff --git a/src/dotnet-scaffolding/dotnet-scaffold/README.md b/src/dotnet-scaffolding/dotnet-scaffold/README.md index 5b5aced272..427a092374 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/README.md +++ b/src/dotnet-scaffolding/dotnet-scaffold/README.md @@ -1,2 +1,14 @@ New and improved scaffolding experience. -More details coming soon! \ No newline at end of file +More details coming soon! + +## Identity for MVC and Razor Pages + +Add local ASP.NET Core Identity to an MVC or Razor Pages project: + +```powershell +dotnet scaffold aspnet identity --project .\MyApp\MyApp.csproj --dataContext ApplicationDbContext --dbProvider sqlite-efcore +``` + +Use `--prerelease` when targeting a preview of .NET. The scaffolder adds Identity pages, host configuration, login navigation, and an initial EF Core migration. It does not apply the migration or update the database. + +Existing Identity registrations, user types, and login partials are preserved. Running the same command again without `--overwrite` leaves the generated source unchanged. For customized layouts without a recognizable navbar, the scaffolder generates `_LoginPartial.cshtml` and reports where to add the reference manually. \ No newline at end of file diff --git a/src/dotnet-scaffolding/dotnet-scaffold/dotnet-scaffold.csproj b/src/dotnet-scaffolding/dotnet-scaffold/dotnet-scaffold.csproj index a31ab7682d..44d7a9eeb7 100644 --- a/src/dotnet-scaffolding/dotnet-scaffold/dotnet-scaffold.csproj +++ b/src/dotnet-scaffolding/dotnet-scaffold/dotnet-scaffold.csproj @@ -232,6 +232,10 @@ would override the PackagePath to net9.0 and cause a duplicate-file NU5118 error. --> + + + + PreserveNewest AspNet\Templates\net8.0\Files\_ValidationScriptsPartial.cshtml @@ -261,6 +265,7 @@ + <_AspNetConfigs_net8 Include="$(OutputPath)AspNet\Templates\net8.0\CodeModificationConfigs\*.json" /> <_AspNetConfigs_net9 Include="$(OutputPath)AspNet\Templates\net9.0\CodeModificationConfigs\*.json" /> <_AspNetConfigs_net10 Include="$(OutputPath)AspNet\Templates\net10.0\CodeModificationConfigs\*.json" /> diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Extensions/IdentityScaffolderBuilderExtensionsTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Extensions/IdentityScaffolderBuilderExtensionsTests.cs index c6b8293817..8fcf251810 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Extensions/IdentityScaffolderBuilderExtensionsTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Extensions/IdentityScaffolderBuilderExtensionsTests.cs @@ -48,7 +48,7 @@ public void WithIdentityCodeChangeStep_ReturnsBuilder() { // Arrange Mock mockBuilder = new Mock(); - mockBuilder.Setup(b => b.WithStep(It.IsAny>>())) + mockBuilder.Setup(b => b.WithStep(It.IsAny>>())) .Returns(mockBuilder.Object); // Act @@ -56,7 +56,7 @@ public void WithIdentityCodeChangeStep_ReturnsBuilder() // Assert Assert.NotNull(result); - mockBuilder.Verify(b => b.WithStep(It.IsAny>>()), Times.Once); + mockBuilder.Verify(b => b.WithStep(It.IsAny>>()), Times.Once); } [Fact] diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/IdentityHelperTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/IdentityHelperTests.cs index 01eff0d239..c2ea0ed80c 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/IdentityHelperTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Helpers/IdentityHelperTests.cs @@ -4,12 +4,15 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.DotNet.Scaffolding.TextTemplating; using Microsoft.DotNet.Tools.Scaffold.AspNet.Common; using Microsoft.DotNet.Tools.Scaffold.AspNet.Helpers; using Microsoft.DotNet.Tools.Scaffold.AspNet.Models; using Microsoft.DotNet.Tools.Scaffold.AspNet.Templates.net11.Files; using Xunit; +using ProjectInfo = Microsoft.DotNet.Tools.Scaffold.AspNet.Common.ProjectInfo; namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.Helpers; @@ -138,11 +141,99 @@ public void GetApplicationUserTextTemplatingProperty_WithValidUserClassName_Incl Assert.Contains("CustomUser", result.OutputPath); } - private IdentityModel CreateTestIdentityModel() + [Theory] + [InlineData("DifferentFolder")] + [InlineData("Folder.With.Dots")] + public void GetTextTemplatingProperties_UsesActualProjectDirectory(string directoryName) + { + var model = CreateTestIdentityModel(); + model.BaseOutputPath = Path.Combine(Path.GetTempPath(), directoryName); + var templatePath = Path.Combine("Identity", "Templates", "Identity", "Pages", "Account", "Login.tt"); + + var property = Assert.Single(IdentityHelper.GetTextTemplatingProperties([templatePath], model)); + + Assert.Equal(Path.Combine(model.BaseOutputPath, "Areas", "Identity", "Pages", "Account", "Login.cshtml"), property.OutputPath); + } + + [Theory] + [InlineData("ApplicationDbContext", "App", true)] + [InlineData("OldApplicationDbContext", "App", false)] + [InlineData("ApplicationDbContext", "Other", false)] + public void HasMigration_MatchesExactContext(string name, string ns, bool expected) + { + var compilation = CSharpCompilation.Create("Snapshots", + [CSharpSyntaxTree.ParseText($$""" +using System; +public class DbContextAttribute(Type context) : Attribute {} +public class ModelSnapshot {} +namespace {{ns}} +{ + public class {{name}} {} + // ApplicationDbContext may also appear in unrelated snapshots. + [DbContext(typeof({{name}}))] + public class Snapshot : ModelSnapshot {} +} +""")], [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]); + var snapshot = compilation.GetTypeByMetadataName($"{ns}.Snapshot")!; + Assert.Equal(expected, IdentityHelper.HasMigration([snapshot], new DbContextInfo + { + DbContextClassName = "ApplicationDbContext", + DbContextNamespace = "App" + })); + } + + [Fact] + public void GetApplicationUserTextTemplatingProperty_PreservesExistingUser() + { + var model = CreateTestIdentityModel(); + model.HasExistingUser = true; + Assert.Null(IdentityHelper.GetApplicationUserTextTemplatingProperty("ApplicationUser.tt", model)); + } + + [Theory] + [InlineData(false, "@Url.Action")] + [InlineData(true, "@Url.Page")] + public void LoginPartial_RendersHostNavigation(bool isRazorPages, string returnUrl) + { + var model = CreateTestIdentityModel(); + model.IsRazorPages = isRazorPages; + ITextTransformation template = new Microsoft.DotNet.Tools.Scaffold.AspNet.Templates.net10.Files._LoginPartial + { + Session = new Dictionary { ["Model"] = model } + }; + template.Initialize(); + var content = template.TransformText(); + Assert.Contains("SignInManager", content); + Assert.Contains(returnUrl, content); + Assert.Contains("asp-page=\"/Account/Logout\"", content); + Assert.Contains("asp-page=\"/Account/Register\"", content); + } + + [Fact] + public void GetLoginPartialTextTemplatingProperty_DoesNotOverwriteCustomPartial() + { + var directory = Path.Combine(Path.GetTempPath(), nameof(IdentityHelperTests), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(directory, "Views", "Shared")); + try + { + var model = CreateTestIdentityModel(Path.Combine(directory, "TestProject.csproj")); + var property = IdentityHelper.GetLoginPartialTextTemplatingProperty("_LoginPartial.tt", model); + Assert.NotNull(property); + File.WriteAllText(property.OutputPath, "Custom navigation"); + Assert.Null(IdentityHelper.GetLoginPartialTextTemplatingProperty("_LoginPartial.tt", model)); + Assert.Equal("Custom navigation", File.ReadAllText(property.OutputPath)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private IdentityModel CreateTestIdentityModel(string? projectPath = null) { return new IdentityModel { - ProjectInfo = new ProjectInfo(Path.Combine("test", "project", "TestProject.csproj")), + ProjectInfo = new ProjectInfo(projectPath ?? Path.Combine("test", "project", "TestProject.csproj")), IdentityNamespace = "TestNamespace", BaseOutputPath = Path.Combine("Areas", "Identity"), UserClassName = "ApplicationUser", diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs deleted file mode 100644 index da72dc9d90..0000000000 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndNet10Tests.cs +++ /dev/null @@ -1,592 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Sockets; -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Microsoft.DotNet.Tools.Scaffold.Tests.Helpers; -using Xunit; - -namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.Integration.Identity; - -[Trait("Suite", "ScaffoldIntegration")] -[Trait("Family", "identity")] -public class IdentityEndToEndNet10Tests -{ - [Theory] - [InlineData("mvc", "Views")] - [InlineData("webapp", "Pages")] - public async Task ScaffoldIdentity_ConfiguresCleanProject(string templateName, string hostFolder) - { - var projectName = templateName == "mvc" ? "MvcNoAuth" : "RazorNoAuth"; - var testDirectory = Path.Combine(Path.GetTempPath(), nameof(IdentityEndToEndNet10Tests), Guid.NewGuid().ToString("N")); - var projectDirectory = Path.Combine(testDirectory, projectName); - var projectPath = Path.Combine(projectDirectory, $"{projectName}.csproj"); - - Directory.CreateDirectory(testDirectory); - try - { - var createResult = await RunDotNetAsync( - testDirectory, - "new", templateName, - "--name", projectName, - "--output", projectDirectory, - "--framework", "net10.0", - "--auth", "None", - "--no-restore"); - Assert.True(createResult.ExitCode == 0, $"Project creation failed.{Environment.NewLine}{createResult.Output}{Environment.NewLine}{createResult.Error}"); - - var scaffoldResult = await ScaffoldCliHelper.RunScaffoldAsync( - "net10.0", - "identity", - "--project", projectPath, - "--dataContext", "ApplicationDbContext", - "--dbProvider", "sqlite-efcore"); - Assert.True(scaffoldResult.ExitCode == 0, $"Identity scaffolding failed.{Environment.NewLine}{scaffoldResult.Output}{Environment.NewLine}{scaffoldResult.Error}"); - - AssertConfiguredProject(projectDirectory, hostFolder); - - var buildResult = await ScaffoldCliHelper.RunBuildForFrameworkAsync(projectDirectory, "net10.0"); - Assert.True(buildResult.ExitCode == 0, $"Scaffolded project failed to build.{Environment.NewLine}{buildResult.Output}{Environment.NewLine}{buildResult.Error}"); - - var sourceHashes = GetSourceHashes(projectDirectory); - var repeatResult = await ScaffoldCliHelper.RunScaffoldAsync( - "net10.0", - "identity", - "--project", projectPath, - "--dataContext", "ApplicationDbContext", - "--dbProvider", "sqlite-efcore"); - Assert.True(repeatResult.ExitCode == 0, $"Repeated Identity scaffolding failed.{Environment.NewLine}{repeatResult.Output}{Environment.NewLine}{repeatResult.Error}"); - Assert.Equal(sourceHashes, GetSourceHashes(projectDirectory)); - - await AssertIdentityEndpointsAsync(projectPath); - } - finally - { - try - { - Directory.Delete(testDirectory, recursive: true); - } - catch - { - // Best-effort cleanup; preserve any test failure. - } - } - } - - [Theory] - [InlineData("mvc")] - [InlineData("webapp")] - public async Task ScaffoldIdentity_SecondRunDoesNotChangeProjectWithDefaultIdentityUi(string templateName) - { - var projectName = templateName == "mvc" ? "MvcIdentity" : "RazorIdentity"; - var testDirectory = Path.Combine(Path.GetTempPath(), nameof(IdentityEndToEndNet10Tests), Guid.NewGuid().ToString("N")); - var projectDirectory = Path.Combine(testDirectory, projectName); - var projectPath = Path.Combine(projectDirectory, $"{projectName}.csproj"); - - Directory.CreateDirectory(testDirectory); - try - { - var createResult = await RunDotNetAsync( - testDirectory, - "new", templateName, - "--name", projectName, - "--output", projectDirectory, - "--framework", "net10.0", - "--auth", "Individual", - "--use-local-db", "false"); - Assert.True(createResult.ExitCode == 0, $"Project creation failed.{Environment.NewLine}{createResult.Output}{Environment.NewLine}{createResult.Error}"); - - var firstScaffoldResult = await ScaffoldCliHelper.RunScaffoldAsync( - "net10.0", - "identity", - "--project", projectPath, - "--dataContext", "ApplicationDbContext", - "--dbProvider", "sqlite-efcore"); - Assert.True(firstScaffoldResult.ExitCode == 0, $"Initial Identity scaffolding failed.{Environment.NewLine}{firstScaffoldResult.Output}{Environment.NewLine}{firstScaffoldResult.Error}"); - - var sourceHashes = GetSourceHashes(projectDirectory); - var secondScaffoldResult = await ScaffoldCliHelper.RunScaffoldAsync( - "net10.0", - "identity", - "--project", projectPath, - "--dataContext", "ApplicationDbContext", - "--dbProvider", "sqlite-efcore"); - Assert.True(secondScaffoldResult.ExitCode == 0, $"Repeated Identity scaffolding failed.{Environment.NewLine}{secondScaffoldResult.Output}{Environment.NewLine}{secondScaffoldResult.Error}"); - Assert.Equal(sourceHashes, GetSourceHashes(projectDirectory)); - } - finally - { - try - { - Directory.Delete(testDirectory, recursive: true); - } - catch - { - // Best-effort cleanup; preserve any test failure. - } - } - } - - [Theory] - [InlineData("builder.Services.AddIdentity()\n .AddEntityFrameworkStores()\n .AddDefaultTokenProviders()\n .AddDefaultUI();")] - [InlineData("builder.Services.AddIdentityCore()\n .AddRoles()\n .AddEntityFrameworkStores()\n .AddSignInManager()\n .AddDefaultTokenProviders()\n .AddDefaultUI();")] - public async Task ScaffoldIdentity_PreservesExistingIdentityRegistration(string identityRegistration) - { - var projectName = "MvcCustomIdentity"; - var testDirectory = Path.Combine(Path.GetTempPath(), nameof(IdentityEndToEndNet10Tests), Guid.NewGuid().ToString("N")); - var projectDirectory = Path.Combine(testDirectory, projectName); - var projectPath = Path.Combine(projectDirectory, $"{projectName}.csproj"); - - Directory.CreateDirectory(testDirectory); - try - { - var createResult = await RunDotNetAsync( - testDirectory, - "new", "mvc", - "--name", projectName, - "--output", projectDirectory, - "--framework", "net10.0", - "--auth", "None", - "--no-restore"); - Assert.True(createResult.ExitCode == 0, $"Project creation failed.{Environment.NewLine}{createResult.Output}{Environment.NewLine}{createResult.Error}"); - - var programPath = Path.Combine(projectDirectory, "Program.cs"); - var programContent = File.ReadAllText(programPath); - var customIdentitySetup = $""" -builder.Services.AddDbContext(options => - options.UseSqlite("Data Source=identity.db")); -{identityRegistration} -"""; - programContent = programContent.Replace( - "builder.Services.AddControllersWithViews();", - $"builder.Services.AddControllersWithViews();{Environment.NewLine}{customIdentitySetup}", - StringComparison.Ordinal); - File.WriteAllText(programPath, programContent); - - var scaffoldResult = await ScaffoldCliHelper.RunScaffoldAsync( - "net10.0", - "identity", - "--project", projectPath, - "--dataContext", "ApplicationDbContext", - "--dbProvider", "sqlite-efcore"); - Assert.True(scaffoldResult.ExitCode == 0, $"Identity scaffolding failed.{Environment.NewLine}{scaffoldResult.Output}{Environment.NewLine}{scaffoldResult.Error}"); - - programContent = File.ReadAllText(programPath); - Assert.DoesNotContain("AddDefaultIdentity", programContent); - Assert.Equal(1, CountOccurrences(programContent, identityRegistration.Split('(')[0])); - - var buildResult = await ScaffoldCliHelper.RunBuildForFrameworkAsync(projectDirectory, "net10.0"); - Assert.True(buildResult.ExitCode == 0, $"Scaffolded project failed to build.{Environment.NewLine}{buildResult.Output}{Environment.NewLine}{buildResult.Error}"); - } - finally - { - try - { - Directory.Delete(testDirectory, recursive: true); - } - catch - { - // Best-effort cleanup; preserve any test failure. - } - } - } - - [Fact] - public async Task ScaffoldIdentity_SupportsCompleteAccountLifecycle() - { - const string projectName = "MvcIdentityLifecycle"; - var testDirectory = Path.Combine(Path.GetTempPath(), nameof(IdentityEndToEndNet10Tests), Guid.NewGuid().ToString("N")); - var projectDirectory = Path.Combine(testDirectory, projectName); - var projectPath = Path.Combine(projectDirectory, $"{projectName}.csproj"); - - Directory.CreateDirectory(testDirectory); - try - { - var createResult = await RunDotNetAsync( - testDirectory, - "new", "mvc", - "--name", projectName, - "--output", projectDirectory, - "--framework", "net10.0", - "--auth", "None", - "--no-restore"); - Assert.True(createResult.ExitCode == 0, $"Project creation failed.{Environment.NewLine}{createResult.Output}{Environment.NewLine}{createResult.Error}"); - - var scaffoldResult = await ScaffoldCliHelper.RunScaffoldAsync( - "net10.0", - "identity", - "--project", projectPath, - "--dataContext", "ApplicationDbContext", - "--dbProvider", "sqlite-efcore"); - Assert.True(scaffoldResult.ExitCode == 0, $"Identity scaffolding failed.{Environment.NewLine}{scaffoldResult.Output}{Environment.NewLine}{scaffoldResult.Error}"); - - var buildResult = await ScaffoldCliHelper.RunBuildForFrameworkAsync(projectDirectory, "net10.0"); - Assert.True(buildResult.ExitCode == 0, $"Scaffolded project failed to build.{Environment.NewLine}{buildResult.Output}{Environment.NewLine}{buildResult.Error}"); - - await AssertIdentityAccountLifecycleAsync(projectPath); - } - finally - { - try - { - Directory.Delete(testDirectory, recursive: true); - } - catch - { - // Best-effort cleanup; preserve any test failure. - } - } - } - - private static void AssertConfiguredProject(string projectDirectory, string hostFolder) - { - var programContent = File.ReadAllText(Path.Combine(projectDirectory, "Program.cs")); - Assert.Contains("AddDatabaseDeveloperPageExceptionFilter", programContent); - Assert.Contains("AddRazorPages", programContent); - Assert.Contains("UseMigrationsEndPoint", programContent); - Assert.DoesNotContain("UseAuthentication", programContent); - Assert.Contains("MapRazorPages", programContent); - Assert.Contains("WithStaticAssets", programContent); - - var sharedDirectory = Path.Combine(projectDirectory, hostFolder, "Shared"); - var layoutContent = File.ReadAllText(Path.Combine(sharedDirectory, "_Layout.cshtml")); - var loginPartialContent = File.ReadAllText(Path.Combine(sharedDirectory, "_LoginPartial.cshtml")); - Assert.Contains("", layoutContent); - Assert.Contains("asp-page=\"/Account/Login\"", loginPartialContent); - Assert.Contains("asp-page=\"/Account/Register\"", loginPartialContent); - - var migrationsDirectory = Path.Combine(projectDirectory, "Data", "Migrations"); - Assert.True(Directory.Exists(migrationsDirectory)); - Assert.Contains(Directory.GetFiles(migrationsDirectory), path => path.EndsWith("_CreateIdentitySchema.cs", StringComparison.Ordinal)); - Assert.Contains(Directory.GetFiles(migrationsDirectory), path => path.EndsWith("ApplicationDbContextModelSnapshot.cs", StringComparison.Ordinal)); - Assert.Empty(Directory.GetFiles(projectDirectory, "*.db", SearchOption.AllDirectories)); - } - - private static async Task AssertIdentityEndpointsAsync(string projectPath) - { - var port = GetAvailablePort(); - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = ScaffoldCliHelper.GetDotNetPath(), - WorkingDirectory = Path.GetDirectoryName(projectPath)!, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - process.StartInfo.ArgumentList.Add("run"); - process.StartInfo.ArgumentList.Add("--no-build"); - process.StartInfo.ArgumentList.Add("--project"); - process.StartInfo.ArgumentList.Add(projectPath); - process.StartInfo.ArgumentList.Add("--urls"); - process.StartInfo.ArgumentList.Add($"http://127.0.0.1:{port}"); - - var output = new StringBuilder(); - process.OutputDataReceived += (_, args) => output.AppendLine(args.Data); - process.ErrorDataReceived += (_, args) => output.AppendLine(args.Data); - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - try - { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var rootContent = await GetWithRetryAsync(client, $"http://127.0.0.1:{port}/", process, output); - Assert.Contains("/Identity/Account/Login", rootContent); - Assert.Contains("/Identity/Account/Register", rootContent); - - await GetWithRetryAsync(client, $"http://127.0.0.1:{port}/Identity/Account/Login", process, output); - await GetWithRetryAsync(client, $"http://127.0.0.1:{port}/Identity/Account/Register", process, output); - } - finally - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(); - } - } - } - - private static async Task AssertIdentityAccountLifecycleAsync(string projectPath) - { - var port = GetAvailablePort(); - var baseAddress = new Uri($"http://127.0.0.1:{port}"); - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = ScaffoldCliHelper.GetDotNetPath(), - WorkingDirectory = Path.GetDirectoryName(projectPath)!, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - process.StartInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; - process.StartInfo.ArgumentList.Add("run"); - process.StartInfo.ArgumentList.Add("--no-build"); - process.StartInfo.ArgumentList.Add("--project"); - process.StartInfo.ArgumentList.Add(projectPath); - process.StartInfo.ArgumentList.Add("--urls"); - process.StartInfo.ArgumentList.Add(baseAddress.ToString()); - - var output = new StringBuilder(); - process.OutputDataReceived += (_, args) => output.AppendLine(args.Data); - process.ErrorDataReceived += (_, args) => output.AppendLine(args.Data); - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - try - { - using var handler = new HttpClientHandler - { - CookieContainer = new CookieContainer(), - AllowAutoRedirect = true - }; - using var client = new HttpClient(handler) - { - BaseAddress = baseAddress, - Timeout = TimeSpan.FromSeconds(30) - }; - - var migrationProbePage = await GetWithRetryAsync(client, "/Identity/Account/Register", process, output); - await ApplyIdentityMigrationAsync(client, migrationProbePage); - Assert.True(File.Exists(Path.Combine(Path.GetDirectoryName(projectPath)!, "ApplicationDbContext.db"))); - - var registerPage = await client.GetStringAsync("/Identity/Account/Register"); - await AssertSuccessfulGetAsync(client, "/Identity/lib/bootstrap/dist/css/bootstrap.min.css"); - await AssertSuccessfulGetAsync(client, "/Identity/lib/bootstrap/dist/js/bootstrap.bundle.min.js"); - var email = $"identity-{Guid.NewGuid():N}@example.com"; - const string password = "Test1234!"; - var registerResponse = await PostFormAsync( - client, - "/Identity/Account/Register", - registerPage, - new Dictionary - { - ["Input.Email"] = email, - ["Input.Password"] = password, - ["Input.ConfirmPassword"] = password - }); - Assert.Contains("Register confirmation", registerResponse, StringComparison.OrdinalIgnoreCase); - - var confirmationPage = await client.GetStringAsync(GetLink(registerResponse, "ConfirmEmail")); - Assert.Contains("Thank you for confirming your email", confirmationPage, StringComparison.OrdinalIgnoreCase); - - var loginPage = await client.GetStringAsync("/Identity/Account/Login"); - var loginResponse = await PostFormAsync( - client, - "/Identity/Account/Login", - loginPage, - new Dictionary - { - ["Input.Email"] = email, - ["Input.Password"] = password, - ["Input.RememberMe"] = "false" - }); - Assert.Contains($"Hello {email}!", loginResponse, StringComparison.OrdinalIgnoreCase); - - var managePage = await client.GetStringAsync(GetLink(loginResponse, "/Account/Manage")); - Assert.Contains("

Profile

", managePage, StringComparison.OrdinalIgnoreCase); - Assert.Contains(email, managePage, StringComparison.OrdinalIgnoreCase); - - var authenticatedHomePage = await client.GetStringAsync("/"); - var logoutResponse = await PostFormAsync( - client, - "/Identity/Account/Logout?returnUrl=%2F", - authenticatedHomePage, - new Dictionary()); - Assert.Contains(">Login<", logoutResponse, StringComparison.OrdinalIgnoreCase); - Assert.Contains(">Register<", logoutResponse, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain($"Hello {email}!", logoutResponse, StringComparison.OrdinalIgnoreCase); - } - finally - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(); - } - } - } - - private static async Task ApplyIdentityMigrationAsync(HttpClient client, string registerPage) - { - var formFields = new Dictionary - { - ["Input.Email"] = $"migration-probe-{Guid.NewGuid():N}@example.com", - ["Input.Password"] = "Test1234!", - ["Input.ConfirmPassword"] = "Test1234!", - ["__RequestVerificationToken"] = GetAntiforgeryToken(registerPage) - }; - using var failedRegistration = await client.PostAsync("/Identity/Account/Register", new FormUrlEncodedContent(formFields)); - var errorPage = await failedRegistration.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.InternalServerError, failedRegistration.StatusCode); - - var context = GetAttributeValue(errorPage, "data-assemblyname"); - using var migrationResponse = await client.PostAsync( - "/ApplyDatabaseMigrations", - new FormUrlEncodedContent(new Dictionary { ["context"] = context })); - var migrationResponseContent = await migrationResponse.Content.ReadAsStringAsync(); - Assert.True( - migrationResponse.StatusCode == HttpStatusCode.NoContent, - $"Applying the generated migration returned {(int)migrationResponse.StatusCode}.{Environment.NewLine}{migrationResponseContent}"); - } - - private static async Task PostFormAsync( - HttpClient client, - string requestUri, - string pageContent, - IReadOnlyDictionary fields) - { - var formFields = new Dictionary(fields) - { - ["__RequestVerificationToken"] = GetAntiforgeryToken(pageContent) - }; - using var response = await client.PostAsync(requestUri, new FormUrlEncodedContent(formFields)); - var responseContent = await response.Content.ReadAsStringAsync(); - Assert.True(response.IsSuccessStatusCode, $"POST {requestUri} returned {(int)response.StatusCode}.{Environment.NewLine}{responseContent}"); - return responseContent; - } - - private static async Task AssertSuccessfulGetAsync(HttpClient client, string requestUri) - { - using var response = await client.GetAsync(requestUri); - Assert.True(response.IsSuccessStatusCode, $"GET {requestUri} returned {(int)response.StatusCode}."); - Assert.NotEmpty(await response.Content.ReadAsByteArrayAsync()); - } - - private static string GetAntiforgeryToken(string pageContent) - => GetInputValue(pageContent, "__RequestVerificationToken"); - - private static string GetInputValue(string pageContent, string inputName) - { - var match = Regex.Match( - pageContent, - $"]+name=\"{Regex.Escape(inputName)}\"[^>]+value=\"([^\"]+)", - RegexOptions.IgnoreCase); - Assert.True(match.Success, $"The page did not contain an input named '{inputName}'."); - return WebUtility.HtmlDecode(match.Groups[1].Value); - } - - private static string GetAttributeValue(string pageContent, string attributeName) - { - var match = Regex.Match( - pageContent, - $"{Regex.Escape(attributeName)}=\"([^\"]+)\"", - RegexOptions.IgnoreCase); - Assert.True(match.Success, $"The page did not contain a '{attributeName}' attribute."); - return WebUtility.HtmlDecode(match.Groups[1].Value); - } - - private static string GetLink(string pageContent, string hrefFragment) - { - var match = Regex.Match( - pageContent, - $"href=\"([^\"]*{Regex.Escape(hrefFragment)}[^\"]*)\"", - RegexOptions.IgnoreCase); - Assert.True(match.Success, $"The page did not contain a link with '{hrefFragment}' in its URL."); - return WebUtility.HtmlDecode(match.Groups[1].Value); - } - - private static async Task GetWithRetryAsync(HttpClient client, string url, Process process, StringBuilder output) - { - for (var attempt = 0; attempt < 30; attempt++) - { - if (process.HasExited) - { - Assert.Fail($"The scaffolded application exited unexpectedly.{Environment.NewLine}{output}"); - } - - try - { - using var response = await client.GetAsync(url); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - return await response.Content.ReadAsStringAsync(); - } - catch (HttpRequestException) when (attempt < 29) - { - await Task.Delay(500); - } - } - - Assert.Fail($"The scaffolded application did not become reachable at '{url}'.{Environment.NewLine}{output}"); - return string.Empty; - } - - private static async Task<(int ExitCode, string Output, string Error)> RunDotNetAsync(string workingDirectory, params string[] arguments) - { - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = ScaffoldCliHelper.GetDotNetPath(), - WorkingDirectory = workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - foreach (var argument in arguments) - { - process.StartInfo.ArgumentList.Add(argument); - } - - process.Start(); - var outputTask = process.StandardOutput.ReadToEndAsync(); - var errorTask = process.StandardError.ReadToEndAsync(); - await Task.WhenAll(outputTask, errorTask); - await process.WaitForExitAsync(); - return (process.ExitCode, outputTask.Result, errorTask.Result); - } - - private static SortedDictionary GetSourceHashes(string projectDirectory) - { - return new SortedDictionary( - Directory.GetFiles(projectDirectory, "*", SearchOption.AllDirectories) - .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) - .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) - .ToDictionary( - path => Path.GetRelativePath(projectDirectory, path), - path => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))), - StringComparer.Ordinal); - } - - private static int GetAvailablePort() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; - } - - private static int CountOccurrences(string source, string value) - { - var count = 0; - var index = 0; - while ((index = source.IndexOf(value, index, StringComparison.Ordinal)) >= 0) - { - count++; - index += value.Length; - } - - return count; - } -} diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndTests.cs new file mode 100644 index 0000000000..f2344f1e53 --- /dev/null +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityEndToEndTests.cs @@ -0,0 +1,386 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.DotNet.Tools.Scaffold.Tests.Helpers; +using Xunit; + +namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.Integration.Identity; + +[Trait("Suite", "ScaffoldIntegration")] +[Trait("Family", "identity")] +public class IdentityEndToEndTests +{ + [Theory] + [InlineData("net10.0", "mvc")] + [InlineData("net10.0", "webapp")] + [InlineData("net11.0", "mvc")] + [InlineData("net11.0", "webapp")] + public async Task ScaffoldIdentity_ConfiguresCleanProject(string framework, string template) + { + using var project = new IdentityTestProject(framework, template); + await project.CreateAsync(); + await project.ScaffoldAsync(); + AssertConfiguredProject(project); + await project.BuildAsync(); + await project.AssertUnchangedSecondRunAsync(); + await AssertAccountLifecycleAsync(project); + } + + [Theory] + [InlineData("net10.0", "mvc")] + [InlineData("net10.0", "webapp")] + [InlineData("net11.0", "mvc")] + [InlineData("net11.0", "webapp")] + public async Task ScaffoldIdentity_PreservesDefaultIdentityUi(string framework, string template) + { + using var project = new IdentityTestProject(framework, template); + await project.CreateAsync("Individual"); + var partialPath = Path.Combine(project.Directory, project.HostFolder, "Shared", "_LoginPartial.cshtml"); + var originalPartial = File.ReadAllText(partialPath); + + await project.ScaffoldAsync(); + Assert.Equal(originalPartial, File.ReadAllText(partialPath)); + Assert.False(File.Exists(Path.Combine(project.Directory, "Data", "ApplicationUser.cs"))); + await project.BuildAsync(); + await project.AssertUnchangedSecondRunAsync(); + await AssertAccountLifecycleAsync(project, applyMigration: false); + } + + [Theory] + [InlineData("builder.Services\n .AddDefaultIdentity(options => options.SignIn.RequireConfirmedAccount = true)", false)] + [InlineData("var services = builder.Services;\nservices.AddIdentity(options => options.SignIn.RequireConfirmedAccount = true)", false)] + [InlineData("builder.Services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true)", false)] + [InlineData("builder.Services.AddIdentity(options => options.SignIn.RequireConfirmedAccount = true)", true)] + public async Task ScaffoldIdentity_CompletesPartialRegistration(string registration, bool customUser) + { + using var project = new IdentityTestProject("net10.0", "mvc"); + await project.CreateAsync(); + var programPath = Path.Combine(project.Directory, "Program.cs"); + var program = File.ReadAllText(programPath).Replace( + "builder.Services.AddControllersWithViews();", + $"builder.Services.AddControllersWithViews();\n{registration}.AddEntityFrameworkStores();", + StringComparison.Ordinal); + if (customUser) + { + File.WriteAllText(Path.Combine(project.Directory, "CustomUser.cs"), """ +using Microsoft.AspNetCore.Identity; +namespace IdentityApp.Data; +public class CustomUser : IdentityUser {} +"""); + program = "using IdentityApp.Data;\n" + program; + } + File.WriteAllText(programPath, program); + + await project.ScaffoldAsync(); + if (customUser) + { + Assert.False(File.Exists(Path.Combine(project.Directory, "Data", "ApplicationUser.cs"))); + } + var updated = File.ReadAllText(programPath); + Assert.Contains("options.SignIn.RequireConfirmedAccount = true", updated); + Assert.Equal(registration.Contains("AddDefaultIdentity", StringComparison.Ordinal) ? 1 : 0, + Regex.Matches(updated, @"\.AddDefaultIdentity<").Count); + await project.BuildAsync(); + await project.AssertUnchangedSecondRunAsync(); + await AssertAccountLifecycleAsync(project); + } + + [Fact] + public async Task ScaffoldIdentity_ResolvesRelativeProjectPath() + { + using var project = new IdentityTestProject("net10.0", "mvc"); + await project.CreateAsync(); + await project.ScaffoldAsync(relativeProjectPath: true); + AssertConfiguredProject(project); + await project.BuildAsync(); + await project.AssertUnchangedSecondRunAsync(relativeProjectPath: true); + } + + [Fact] + public async Task ScaffoldIdentity_UsesArtifactsOutput() + { + using var project = new IdentityTestProject("net10.0", "mvc"); + await project.CreateAsync(); + File.WriteAllText(Path.Combine(project.Directory, "Directory.Build.props"), """ + + + true + $(MSBuildThisFileDirectory)artifacts + + +"""); + + await project.ScaffoldAsync(); + AssertConfiguredProject(project); + await project.BuildAsync(); + await project.AssertUnchangedSecondRunAsync(); + } + + private static void AssertConfiguredProject(IdentityTestProject project) + { + var program = File.ReadAllText(Path.Combine(project.Directory, "Program.cs")); + Assert.Contains("AddDatabaseDeveloperPageExceptionFilter", program); + Assert.Contains("AddRazorPages", program); + Assert.Contains("UseMigrationsEndPoint", program); + Assert.Contains("MapRazorPages", program); + var shared = Path.Combine(project.Directory, project.HostFolder, "Shared"); + Assert.Contains("", File.ReadAllText(Path.Combine(shared, "_Layout.cshtml"))); + var partial = File.ReadAllText(Path.Combine(shared, "_LoginPartial.cshtml")); + Assert.Contains("asp-page=\"/Account/Login\"", partial); + Assert.Contains("asp-page=\"/Account/Register\"", partial); + var migrations = Path.Combine(project.Directory, "Data", "Migrations"); + Assert.NotEmpty(System.IO.Directory.GetFiles(migrations, "*_CreateIdentitySchema.cs")); + Assert.NotEmpty(System.IO.Directory.GetFiles(migrations, "*ModelSnapshot.cs")); + Assert.Empty(System.IO.Directory.GetFiles(project.Directory, "*.db", SearchOption.AllDirectories)); + } + + private static async Task AssertAccountLifecycleAsync(IdentityTestProject project, bool applyMigration = true) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + var baseAddress = new Uri($"http://127.0.0.1:{port}"); + using var process = new Process + { + StartInfo = ScaffoldCliHelper.CreateDotNetStartInfo(project.Directory, + "run", "--no-build", "--no-launch-profile", "--framework", project.Framework, + "--project", project.Path, "--urls", baseAddress.ToString()) + }; + process.StartInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; + process.Start(); + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + try + { + using var handler = new HttpClientHandler { CookieContainer = new CookieContainer() }; + using var client = new HttpClient(handler) { BaseAddress = baseAddress, Timeout = TimeSpan.FromSeconds(30) }; + var registerPage = await WaitForPageAsync(client, "/Identity/Account/Register", process, output, error); + var homePage = await client.GetStringAsync("/"); + Assert.Contains("/Identity/Account/Login", homePage); + Assert.Contains("/Identity/Account/Register", homePage); + if (applyMigration) + { + await ApplyMigrationAsync(client, registerPage); + } + + registerPage = await client.GetStringAsync("/Identity/Account/Register"); + Assert.NotEmpty(await client.GetByteArrayAsync("/Identity/lib/bootstrap/dist/css/bootstrap.min.css")); + Assert.NotEmpty(await client.GetByteArrayAsync("/Identity/lib/bootstrap/dist/js/bootstrap.bundle.min.js")); + var email = $"identity-{Guid.NewGuid():N}@example.com"; + const string password = "Test1234!"; + var registration = await PostFormAsync(client, "/Identity/Account/Register", registerPage, new() + { + ["Input.Email"] = email, + ["Input.Password"] = password, + ["Input.ConfirmPassword"] = password + }); + Assert.Contains("Register confirmation", registration, StringComparison.OrdinalIgnoreCase); + var confirmation = await client.GetStringAsync(GetLink(registration, "ConfirmEmail")); + Assert.Contains("Thank you for confirming your email", confirmation, StringComparison.OrdinalIgnoreCase); + + var login = await PostFormAsync(client, "/Identity/Account/Login", + await client.GetStringAsync("/Identity/Account/Login"), new() + { + ["Input.Email"] = email, + ["Input.Password"] = password, + ["Input.RememberMe"] = "false" + }); + Assert.Contains($"Hello {email}!", login, StringComparison.OrdinalIgnoreCase); + var profile = await client.GetStringAsync(GetLink(login, "/Account/Manage")); + Assert.Contains("

Profile

", profile, StringComparison.OrdinalIgnoreCase); + Assert.Contains(email, profile, StringComparison.OrdinalIgnoreCase); + + var logout = await PostFormAsync(client, "/Identity/Account/Logout?returnUrl=%2F", + await client.GetStringAsync("/"), new()); + Assert.Contains(">Login<", logout, StringComparison.OrdinalIgnoreCase); + Assert.Contains(">Register<", logout, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain($"Hello {email}!", logout, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + await process.WaitForExitAsync(); + await Task.WhenAll(output, error); + } + } + + private static async Task ApplyMigrationAsync(HttpClient client, string registerPage) + { + using var failedRegistration = await client.PostAsync("/Identity/Account/Register", new FormUrlEncodedContent(new Dictionary + { + ["Input.Email"] = $"migration-probe-{Guid.NewGuid():N}@example.com", + ["Input.Password"] = "Test1234!", + ["Input.ConfirmPassword"] = "Test1234!", + ["__RequestVerificationToken"] = GetAntiforgeryToken(registerPage) + })); + var errorPage = await failedRegistration.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.InternalServerError, failedRegistration.StatusCode); + var context = MatchHtml(errorPage, "data-assemblyname=\"([^\"]+)\""); + using var migration = await client.PostAsync("/ApplyDatabaseMigrations", + new FormUrlEncodedContent(new Dictionary { ["context"] = context })); + Assert.True(migration.StatusCode == HttpStatusCode.NoContent, + $"Applying the migration returned {(int)migration.StatusCode}.\n{await migration.Content.ReadAsStringAsync()}"); + } + + private static async Task PostFormAsync(HttpClient client, string uri, string page, Dictionary fields) + { + fields["__RequestVerificationToken"] = GetAntiforgeryToken(page); + using var response = await client.PostAsync(uri, new FormUrlEncodedContent(fields)); + var content = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"POST {uri} returned {(int)response.StatusCode}.\n{content}"); + return content; + } + + private static string GetAntiforgeryToken(string page) + => MatchHtml(page, "]+name=\"__RequestVerificationToken\"[^>]+value=\"([^\"]+)"); + + private static string GetLink(string page, string fragment) + => MatchHtml(page, $"href=\"([^\"]*{Regex.Escape(fragment)}[^\"]*)\""); + + private static string MatchHtml(string page, string pattern) + { + var match = Regex.Match(page, pattern, RegexOptions.IgnoreCase); + Assert.True(match.Success, $"Expected HTML matching '{pattern}'.\n{page}"); + return WebUtility.HtmlDecode(match.Groups[1].Value); + } + + private static async Task WaitForPageAsync(HttpClient client, string path, Process process, Task output, Task error) + { + for (var attempt = 0; attempt < 60 && !process.HasExited; attempt++) + { + try + { + return await client.GetStringAsync(path); + } + catch (HttpRequestException ex) when (ex.StatusCode is null) + { + await Task.Delay(500); + } + } + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + await process.WaitForExitAsync(); + Assert.Fail($"The scaffolded application did not become reachable.\n{await output}\n{await error}"); + return string.Empty; + } + + private sealed class IdentityTestProject : IDisposable + { + private readonly string _template; + private string _scaffoldOutput = string.Empty; + public string Framework { get; } + public string Directory { get; } + public string Path => System.IO.Path.Combine(Directory, "IdentityApp.csproj"); + public string HostFolder => _template == "webapp" ? "Pages" : "Views"; + + public IdentityTestProject(string framework, string template) + { + Framework = framework; + _template = template; + Directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), nameof(IdentityEndToEndTests), Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(Directory); + if (framework == "net11.0") + { + File.WriteAllText(System.IO.Path.Combine(Directory, "NuGet.config"), ScaffoldCliHelper.PreviewNuGetConfig); + } + } + + public async Task CreateAsync(string authentication = "None") + { + var sdks = await ScaffoldCliHelper.RunDotNetAsync(Directory, "--list-sdks"); + AssertSuccess(sdks); + var sdkVersion = sdks.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]) + .LastOrDefault(version => version.StartsWith(Framework[3..] + ".", StringComparison.Ordinal)); + Assert.False(string.IsNullOrEmpty(sdkVersion), $"An SDK for {Framework} is required.\n{sdks.Output}"); + File.WriteAllText(System.IO.Path.Combine(Directory, "global.json"), + JsonSerializer.Serialize(new { sdk = new { version = sdkVersion, rollForward = "disable", allowPrerelease = true } })); + + var arguments = new List { "new", _template, "--name", "IdentityApp", "--output", Directory, "--framework", Framework, "--auth", authentication }; + if (authentication == "Individual") + { + arguments.AddRange(["--use-local-db", "false"]); + } + else + { + arguments.Add("--no-restore"); + } + AssertSuccess(await ScaffoldCliHelper.RunDotNetAsync(Directory, [.. arguments])); + } + + public async Task ScaffoldAsync(bool relativeProjectPath = false) + { + var arguments = new List { "--project", relativeProjectPath ? "IdentityApp.csproj" : Path, "--dataContext", "ApplicationDbContext", "--dbProvider", "sqlite-efcore" }; + if (Framework == "net11.0") + { + arguments.Add("--prerelease"); + } + var result = relativeProjectPath + ? await ScaffoldCliHelper.RunDotNetAsync(Directory, + ["exec", ScaffoldCliHelper.GetScaffoldAssemblyPath(Framework), "aspnet", "identity", .. arguments]) + : await ScaffoldCliHelper.RunScaffoldAsync(Framework, "identity", [.. arguments]); + _scaffoldOutput = result.Output + Environment.NewLine + result.Error; + AssertSuccess(result); + Assert.True(string.IsNullOrWhiteSpace(result.Error), _scaffoldOutput); + Assert.DoesNotContain("Unable to", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Failed", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.True(File.Exists(System.IO.Path.Combine(Directory, "Areas", "Identity", "Pages", "Account", "Login.cshtml")), + $"Identity pages were not generated.\n{_scaffoldOutput}"); + Assert.True(System.IO.Directory.Exists(System.IO.Path.Combine(Directory, "Data", "Migrations")), + $"Identity migrations were not generated.\n{result.Output}\n{result.Error}"); + } + + public async Task BuildAsync() + => AssertSuccess(await ScaffoldCliHelper.RunBuildForFrameworkAsync(Directory, Framework)); + + public async Task AssertUnchangedSecondRunAsync(bool relativeProjectPath = false) + { + var before = GetSourceHashes(); + var projectBefore = File.ReadAllText(Path); + await ScaffoldAsync(relativeProjectPath); + var after = GetSourceHashes(); + var changed = before.Keys.Union(after.Keys).Where(path => before.GetValueOrDefault(path) != after.GetValueOrDefault(path)).ToList(); + Assert.True(changed.Count == 0, + $"Second scaffolding pass changed: {string.Join(", ", changed)}\nProject before:\n{projectBefore}\nProject after:\n{File.ReadAllText(Path)}\n{_scaffoldOutput}"); + } + + private SortedDictionary GetSourceHashes() + => new(System.IO.Directory.GetFiles(Directory, "*", SearchOption.AllDirectories) + .Where(path => !System.IO.Path.GetRelativePath(Directory, path).Split(System.IO.Path.DirectorySeparatorChar) + .Any(segment => segment is "bin" or "obj" or "artifacts")) + .ToDictionary(path => System.IO.Path.GetRelativePath(Directory, path), + path => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))), StringComparer.Ordinal); + + private static void AssertSuccess((int ExitCode, string Output, string Error) result) + => Assert.True(result.ExitCode == 0, $"Command failed ({result.ExitCode}).\n{result.Output}\n{result.Error}"); + + public void Dispose() + { + try + { + System.IO.Directory.Delete(Directory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Unable to remove test directory '{Directory}': {ex.Message}"); + } + } + } +} diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityIntegrationTestsBase.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityIntegrationTestsBase.cs index d0743fff52..5fe1a96251 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityIntegrationTestsBase.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityIntegrationTestsBase.cs @@ -332,7 +332,7 @@ public void IdentityChangesConfig_ConfiguresIdentityHost() var configPath = Path.Combine(GetActualTemplatesBasePath(), TargetFramework, "CodeModificationConfigs", "identityChanges.json"); var content = File.ReadAllText(configPath); - Assert.Contains("\"CheckBlock\": \"$(IdentityRegistrationCheck)\"", content); + Assert.Contains("\"Options\": [ \"AddDefaultIdentity\" ]", content); Assert.Contains("AddDatabaseDeveloperPageExceptionFilter", content); Assert.Contains("UseMigrationsEndPoint", content); Assert.Contains("AddRazorPages", content); diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet11IntegrationTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet11IntegrationTests.cs index 08ab1e5374..fa55158cd5 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet11IntegrationTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/Integration/Identity/IdentityNet11IntegrationTests.cs @@ -106,6 +106,7 @@ public void Identity_TemplatesMatchNet11DefaultUIBehavior() var registerConfirmationModel = File.ReadAllText(Path.Combine(accountDir, "RegisterConfirmationModel.tt")); Assert.Contains("DisplayConfirmAccountLink = IsNoOpEmailSender();", registerConfirmationModel); + Assert.Contains("\"Microsoft.AspNetCore.Identity.DefaultMessageEmailSender`1\"", registerConfirmationModel); var manageNav = File.ReadAllText(Path.Combine(manageDir, "_ManageNav.tt")); Assert.Contains("aria-current=\"@ManageNavPages.IndexAriaCurrent(ViewContext)\"", manageNav); @@ -151,6 +152,7 @@ public void Identity_PreprocessedTemplatesMatchNet11DefaultUIBehavior() var registerConfirmationModel = RenderTemplate(new Net11RegisterConfirmationModel(), model); Assert.Contains("IEmailSender", registerConfirmationModel); Assert.Contains("DisplayConfirmAccountLink = IsNoOpEmailSender();", registerConfirmationModel); + Assert.Contains("\"Microsoft.AspNetCore.Identity.DefaultMessageEmailSender`1\"", registerConfirmationModel); var manageNav = RenderTemplate(new Net11ManageNav(), model); Assert.Contains("ViewData[\"ManageNav.HasExternalLogins\"]", manageNav); @@ -193,21 +195,21 @@ public async Task Scaffold_Identity_Net11_CliInvocation() var programContent = File.ReadAllText(Path.Combine(_testProjectDir, "Program.cs")); Assert.Contains("TestDbContext", programContent); - // Identity pages may not be generated if T4 template execution fails var identityPagesDir = Path.Combine(_testProjectDir, "Areas", "Identity", "Pages"); - if (Directory.Exists(identityPagesDir)) - { - var accountDir = Path.Combine(identityPagesDir, "Account"); - Assert.True(Directory.Exists(accountDir), "Account directory should be created."); - Assert.True(File.Exists(Path.Combine(accountDir, "Login.cshtml")), "Login.cshtml should be created."); - Assert.True(File.Exists(Path.Combine(accountDir, "Login.cshtml.cs")), "Login.cshtml.cs should be created."); - Assert.True(File.Exists(Path.Combine(accountDir, "Register.cshtml")), "Register.cshtml should be created."); - Assert.True(File.Exists(Path.Combine(accountDir, "Register.cshtml.cs")), "Register.cshtml.cs should be created."); - Assert.True(File.Exists(Path.Combine(accountDir, "Logout.cshtml")), "Logout.cshtml should be created."); - var manageDir = Path.Combine(accountDir, "Manage"); - Assert.True(Directory.Exists(manageDir), "Manage directory should be created."); - Assert.True(File.Exists(Path.Combine(manageDir, "Index.cshtml")), "Manage/Index.cshtml should be created."); - } + Assert.True(Directory.Exists(identityPagesDir), "Identity pages must be generated."); + var accountDir = Path.Combine(identityPagesDir, "Account"); + Assert.True(Directory.Exists(accountDir), "Account directory should be created."); + Assert.True(File.Exists(Path.Combine(accountDir, "Login.cshtml")), "Login.cshtml should be created."); + Assert.True(File.Exists(Path.Combine(accountDir, "Login.cshtml.cs")), "Login.cshtml.cs should be created."); + Assert.True(File.Exists(Path.Combine(accountDir, "Register.cshtml")), "Register.cshtml should be created."); + Assert.True(File.Exists(Path.Combine(accountDir, "Register.cshtml.cs")), "Register.cshtml.cs should be created."); + Assert.True(File.Exists(Path.Combine(accountDir, "Logout.cshtml")), "Logout.cshtml should be created."); + var manageDir = Path.Combine(accountDir, "Manage"); + Assert.True(Directory.Exists(manageDir), "Manage directory should be created."); + Assert.True(File.Exists(Path.Combine(manageDir, "Index.cshtml")), "Manage/Index.cshtml should be created."); + var migrationsDir = Path.Combine(_testProjectDir, "Data", "Migrations"); + Assert.True(Directory.Exists(migrationsDir), $"Identity migration must be generated.\nOutput: {cliOutput}\nError: {cliError}"); + Assert.NotEmpty(Directory.GetFiles(migrationsDir, "*_CreateIdentitySchema.cs")); // Assert no NuGet errors during scaffolding Assert.False(cliOutput.Contains("error: NU"), diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/AddAspNetConnectionStringStepTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/AddAspNetConnectionStringStepTests.cs index 3c839995a1..2d382c0cd0 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/AddAspNetConnectionStringStepTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/AddAspNetConnectionStringStepTests.cs @@ -47,7 +47,7 @@ public async Task ExecuteAsync_CreatesNewAppSettingsFile_WhenFileDoesNotExist() It.IsAny(), "appsettings.json", SearchOption.AllDirectories)) - .Returns(new[] { appSettingsPath }); + .Returns(Array.Empty()); _mockFileSystem.Setup(fs => fs.FileExists(appSettingsPath)).Returns(false); diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs index 880a65a151..ad8b8bd1ae 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/ConfigureIdentityNavigationStepTests.cs @@ -1,7 +1,5 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Microsoft.DotNet.Scaffolding.Core.Scaffolders; @@ -18,166 +16,60 @@ public class ConfigureIdentityNavigationStepTests [Theory] [InlineData(false, "Views")] [InlineData(true, "Pages")] - public async Task ExecuteAsync_AddsLoginPartialAndLayoutReference(bool isRazorPages, string hostFolder) + public async Task ExecuteAsync_UpdatesHostLayout(bool isRazorPages, string hostFolder) { - var projectDirectory = Path.Combine("test", "project"); - var projectPath = Path.Combine(projectDirectory, "TestProject.csproj"); - var sharedDirectory = Path.Combine(projectDirectory, hostFolder, "Shared"); - var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); - var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); - var layoutContent = ""; - var writtenFiles = new Dictionary(); + var projectPath = Path.Combine("test", "project", "TestProject.csproj"); + var layoutPath = Path.Combine("test", "project", hostFolder, "Shared", "_Layout.cshtml"); var fileSystem = new Mock(); fileSystem.Setup(fs => fs.FileExists(layoutPath)).Returns(true); - fileSystem.Setup(fs => fs.FileExists(loginPartialPath)).Returns(false); - fileSystem.Setup(fs => fs.ReadAllText(layoutPath)).Returns(layoutContent); - fileSystem.Setup(fs => fs.WriteAllText(It.IsAny(), It.IsAny())) - .Callback((path, content) => writtenFiles[path] = content); - - var step = new ConfigureIdentityNavigationStep( - NullLogger.Instance, - fileSystem.Object) + fileSystem.Setup(fs => fs.ReadAllText(layoutPath)).Returns("
    "); + var step = new ConfigureIdentityNavigationStep(NullLogger.Instance, fileSystem.Object) { ProjectPath = projectPath, - IsRazorPages = isRazorPages, - UserClassName = "ApplicationUser", - UserClassNamespace = "TestProject.Data" + IsRazorPages = isRazorPages }; - var result = await step.ExecuteAsync(new ScaffolderContext(Mock.Of())); - - Assert.True(result); - Assert.Contains("@inject SignInManager", writtenFiles[loginPartialPath]); - Assert.Contains("", writtenFiles[layoutPath]); + Assert.True(await step.ExecuteAsync(new ScaffolderContext(Mock.Of()))); + fileSystem.Verify(fs => fs.WriteAllText(layoutPath, "
      \n"), Times.Once); } - [Fact] - public async Task ExecuteAsync_DoesNotOverwriteExistingNavigation() - { - var projectDirectory = Path.Combine("test", "project"); - var projectPath = Path.Combine(projectDirectory, "TestProject.csproj"); - var sharedDirectory = Path.Combine(projectDirectory, "Views", "Shared"); - var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); - var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); - var fileSystem = new Mock(); - fileSystem.Setup(fs => fs.FileExists(layoutPath)).Returns(true); - fileSystem.Setup(fs => fs.FileExists(loginPartialPath)).Returns(true); - fileSystem.Setup(fs => fs.ReadAllText(layoutPath)).Returns(""); - - var step = new ConfigureIdentityNavigationStep( - NullLogger.Instance, - fileSystem.Object) - { - ProjectPath = projectPath, - UserClassName = "ApplicationUser", - UserClassNamespace = "TestProject.Data" - }; - - var result = await step.ExecuteAsync(new ScaffolderContext(Mock.Of())); - - Assert.True(result); - fileSystem.Verify(fs => fs.WriteAllText(It.IsAny(), It.IsAny()), Times.Never); - } + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("@await Html.PartialAsync(\"_LoginPartial\")")] + [InlineData("@{ await Html.RenderPartialAsync(\"_LoginPartial\"); }")] + public void AddLoginPartialReference_PreservesExistingReference(string content) + => Assert.Equal(content, ConfigureIdentityNavigationStep.AddLoginPartialReference(content)); - [Fact] - public async Task ExecuteAsync_AddsLoginPartialToNavbarList() + [Theory] + [InlineData("")] + [InlineData("
        \n")] + [InlineData("\n")] + [InlineData("@*
          *@\n")] + [InlineData("\n")] + public void AddLoginPartialReference_TargetsNavbarAndIsIdempotent(string prefix) { - var projectDirectory = Path.Combine("test", "project"); - var projectPath = Path.Combine(projectDirectory, "TestProject.csproj"); - var sharedDirectory = Path.Combine(projectDirectory, "Views", "Shared"); - var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); - var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); - var layoutContent = "
            \n"; - var writtenFiles = new Dictionary(); - var fileSystem = new Mock(); - fileSystem.Setup(fs => fs.FileExists(layoutPath)).Returns(true); - fileSystem.Setup(fs => fs.FileExists(loginPartialPath)).Returns(false); - fileSystem.Setup(fs => fs.ReadAllText(layoutPath)).Returns(layoutContent); - fileSystem.Setup(fs => fs.WriteAllText(It.IsAny(), It.IsAny())) - .Callback((path, content) => writtenFiles[path] = content); - - var step = new ConfigureIdentityNavigationStep( - NullLogger.Instance, - fileSystem.Object) - { - ProjectPath = projectPath, - UserClassName = "ApplicationUser", - UserClassNamespace = "TestProject.Data" - }; + var content = prefix + ""; + var expected = content.Replace(" \n", " \n \n"); - var result = await step.ExecuteAsync(new ScaffolderContext(Mock.Of())); + var actual = ConfigureIdentityNavigationStep.AddLoginPartialReference(content); - Assert.True(result); - Assert.DoesNotContain("
              \n\n \n", writtenFiles[layoutPath]); + Assert.Equal(expected, actual); + Assert.Equal(actual, ConfigureIdentityNavigationStep.AddLoginPartialReference(actual!)); } - [Fact] - public async Task ExecuteAsync_AddsLoginPartialAfterNavbarWithNestedList() - { - var projectDirectory = Path.Combine("test", "project"); - var projectPath = Path.Combine(projectDirectory, "TestProject.csproj"); - var sharedDirectory = Path.Combine(projectDirectory, "Views", "Shared"); - var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); - var loginPartialPath = Path.Combine(sharedDirectory, "_LoginPartial.cshtml"); - var layoutContent = """ - -"""; - var writtenFiles = new Dictionary(); - var fileSystem = new Mock(); - fileSystem.Setup(fs => fs.FileExists(layoutPath)).Returns(true); - fileSystem.Setup(fs => fs.FileExists(loginPartialPath)).Returns(false); - fileSystem.Setup(fs => fs.ReadAllText(layoutPath)).Returns(layoutContent); - fileSystem.Setup(fs => fs.WriteAllText(It.IsAny(), It.IsAny())) - .Callback((path, content) => writtenFiles[path] = content); - - var step = new ConfigureIdentityNavigationStep( - NullLogger.Instance, - fileSystem.Object) - { - ProjectPath = projectPath, - UserClassName = "ApplicationUser", - UserClassNamespace = "TestProject.Data" - }; - - var result = await step.ExecuteAsync(new ScaffolderContext(Mock.Of())); - - Assert.True(result); - var updatedLayout = writtenFiles[layoutPath].Replace("\r\n", "\n", StringComparison.Ordinal); - Assert.DoesNotContain("\n \n ", updatedLayout); - Assert.Contains("\n \n \n", updatedLayout); - } + [Theory] + [InlineData("
              @RenderBody()
              ")] + [InlineData("
                ")] + [InlineData("
                • Unclosed
                • ")] + public void AddLoginPartialReference_LeavesUnsupportedLayoutAlone(string content) + => Assert.Null(ConfigureIdentityNavigationStep.AddLoginPartialReference(content)); [Fact] - public async Task ExecuteAsync_DoesNotCreatePartialWhenNavbarListIsMissing() + public void AddLoginPartialReference_PreservesLineEndings() { - var projectDirectory = Path.Combine("test", "project"); - var projectPath = Path.Combine(projectDirectory, "TestProject.csproj"); - var sharedDirectory = Path.Combine(projectDirectory, "Views", "Shared"); - var layoutPath = Path.Combine(sharedDirectory, "_Layout.cshtml"); - var fileSystem = new Mock(); - fileSystem.Setup(fs => fs.FileExists(layoutPath)).Returns(true); - fileSystem.Setup(fs => fs.ReadAllText(layoutPath)).Returns("
                  @RenderBody()
                  "); - - var step = new ConfigureIdentityNavigationStep( - NullLogger.Instance, - fileSystem.Object) - { - ProjectPath = projectPath, - UserClassName = "ApplicationUser", - UserClassNamespace = "TestProject.Data" - }; - - var result = await step.ExecuteAsync(new ScaffolderContext(Mock.Of())); - - Assert.True(result); - fileSystem.Verify(fs => fs.WriteAllText(It.IsAny(), It.IsAny()), Times.Never); + const string content = "
                    \r\n
                  \r\n"; + Assert.Equal("
                    \r\n
                  \r\n\r\n", + ConfigureIdentityNavigationStep.AddLoginPartialReference(content)); } } diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/IdentityCodeModificationStepTests.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/IdentityCodeModificationStepTests.cs new file mode 100644 index 0000000000..6b111f6c57 --- /dev/null +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/AspNet/ScaffoldSteps/IdentityCodeModificationStepTests.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Linq; +using System.Text.Json; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.DotNet.Tools.Scaffold.AspNet.Helpers; +using Microsoft.DotNet.Tools.Scaffold.AspNet.ScaffoldSteps; +using Xunit; + +namespace Microsoft.DotNet.Tools.Scaffold.Tests.AspNet.ScaffoldSteps; + +public class IdentityCodeModificationStepTests +{ + [Theory] + [InlineData("builder.Services\n.AddDefaultIdentity(options => options.SignIn.RequireConfirmedAccount = false);")] + [InlineData("var services = builder.Services; services.AddIdentity();")] + [InlineData("builder.Services.AddIdentityCore();")] + public void FindIdentityRegistration_RecognizesCallsRegardlessOfFormatting(string source) + => Assert.NotNull(IdentityHelper.FindIdentityRegistration(CSharpSyntaxTree.ParseText(source).GetRoot())); + + [Theory] + [InlineData("// builder.Services.AddIdentity();")] + [InlineData("var example = \"builder.Services.AddIdentityCore()\";")] + [InlineData("builder.Services.AddAuthentication().AddIdentityCookies();")] + public void FindIdentityRegistration_IgnoresNonRegistrations(string source) + => Assert.Null(IdentityHelper.FindIdentityRegistration(CSharpSyntaxTree.ParseText(source).GetRoot())); + + [Theory] + [InlineData("AddIdentity", 3)] + [InlineData("AddIdentityCore", 8)] + [InlineData("AddDefaultIdentity", 1)] + public void GetMissingIdentityChanges_CompletesPartialRegistrations(string method, int expectedChanges) + { + var root = CSharpSyntaxTree.ParseText($"builder.Services.{method}();").GetRoot(); + var registration = IdentityHelper.FindIdentityRegistration(root)!; + var changes = IdentityCodeModificationStep.GetMissingIdentityChanges(root, registration).ToList(); + + Assert.Equal(expectedChanges, changes.Count); + Assert.DoesNotContain(changes, change => JsonSerializer.Serialize(change).Contains("\"Block\":\"builder.Services.AddDefaultIdentity")); + } + + [Fact] + public void GetMissingIdentityChanges_PreservesExistingStore() + { + var root = CSharpSyntaxTree.ParseText(""" +builder.Services.AddDefaultIdentity() + .AddEntityFrameworkStores(); +""").GetRoot(); + Assert.Empty(IdentityCodeModificationStep.GetMissingIdentityChanges(root, IdentityHelper.FindIdentityRegistration(root)!)); + } + + [Fact] + public void GetMissingIdentityChanges_PreservesCustomCookiesAndTokenProviders() + { + var root = CSharpSyntaxTree.ParseText(""" +builder.Services.AddIdentityCore() + .AddDefaultUI() + .AddTokenProvider("Default"); +builder.Services.AddAuthentication().AddCookie(IdentityConstants.ApplicationScheme); +""").GetRoot(); + var changes = IdentityCodeModificationStep.GetMissingIdentityChanges(root, IdentityHelper.FindIdentityRegistration(root)!); + var json = JsonSerializer.Serialize(changes); + + Assert.DoesNotContain("AddApplicationCookie", json); + Assert.DoesNotContain("AddDefaultTokenProviders", json); + Assert.DoesNotContain("AddDefaultUI()", json); + Assert.Contains("AddExternalCookie", json); + Assert.Contains("AddTwoFactorRememberMeCookie", json); + Assert.Contains("AddTwoFactorUserIdCookie", json); + } +} diff --git a/test/dotnet-scaffolding/dotnet-scaffold.Tests/Helpers/ScaffoldCliHelper.cs b/test/dotnet-scaffolding/dotnet-scaffold.Tests/Helpers/ScaffoldCliHelper.cs index 14dc72a9a9..f173c913af 100644 --- a/test/dotnet-scaffolding/dotnet-scaffold.Tests/Helpers/ScaffoldCliHelper.cs +++ b/test/dotnet-scaffolding/dotnet-scaffold.Tests/Helpers/ScaffoldCliHelper.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -36,6 +37,9 @@ public static string GetScaffoldProjectPath() return Path.Combine(GetRepoRoot(), "src", "dotnet-scaffolding", "dotnet-scaffold", "dotnet-scaffold.csproj"); } + internal static string GetScaffoldAssemblyPath(string framework) + => Path.Combine(GetRepoRoot(), "artifacts", "bin", "dotnet-scaffold", GetBuildConfiguration(), framework, "dotnet-scaffold.dll"); + /// /// Gets the path to the dotnet executable. /// On CI, the Arcade build system installs the correct .NET SDK at {repoRoot}/.dotnet/. @@ -130,6 +134,46 @@ private static void ConfigureDotNetEnvironment(ProcessStartInfo startInfo) startInfo.Environment.Remove("MSBuildExtensionsPath"); } + internal static ProcessStartInfo CreateDotNetStartInfo(string workingDirectory, params string[] arguments) + { + var startInfo = new ProcessStartInfo + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + ConfigureDotNetEnvironment(startInfo); + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + return startInfo; + } + + internal static async Task<(int ExitCode, string Output, string Error)> RunDotNetAsync(string workingDirectory, params string[] arguments) + { + using var process = new Process { StartInfo = CreateDotNetStartInfo(workingDirectory, arguments) }; + using var timeout = new CancellationTokenSource(System.TimeSpan.FromMinutes(5)); + process.Start(); + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + try + { + await process.WaitForExitAsync(timeout.Token); + return (process.ExitCode, await output, await error); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + /// /// Detects the build configuration (Debug/Release) from the test assembly's output path. /// The Arcade build layout is: artifacts/bin/{project}/{Config}/{TFM}/{assembly}.dll