Skip to content

Commit 55f8f53

Browse files
Add passkey (WebAuthn/FIDO2) support and sync Blazor Identity scaffolder with .NET 10 template (#3291)
Co-authored-by: MackinnonBuck <[email protected]> Co-authored-by: Mackinnon Buck <[email protected]>
1 parent b278337 commit 55f8f53

File tree

232 files changed

+7004
-2361
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

232 files changed

+7004
-2361
lines changed

src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityGenerator.cs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ private IEnumerable<string> GeneralT4Files
8989
}
9090
}
9191

92+
private IReadOnlyList<(string TemplateFolder, string FullPath)> _allBlazorIdentityStaticFiles;
93+
private IReadOnlyList<(string TemplateFolder, string FullPath)> AllBlazorIdentityStaticFiles
94+
{
95+
get => _allBlazorIdentityStaticFiles ??= [.. BlazorIdentityHelper.GetBlazorIdentityStaticFiles(FileSystem, TemplateFolders)];
96+
}
97+
9298
private IList<Type> _blazorIdentityTemplateTypes;
9399
private IList<Type> BlazorIdentityTemplateTypes
94100
{
@@ -141,6 +147,7 @@ public async Task GenerateCode(BlazorIdentityCommandLineModel model)
141147

142148
var blazorTemplateModel = await ValidateAndBuild(model);
143149
ExecuteTemplates(blazorTemplateModel);
150+
AddStaticFiles(blazorTemplateModel);
144151
AddReadmeFile(blazorTemplateModel.BaseOutputPath);
145152
await ModifyFilesAsync(blazorTemplateModel);
146153
}
@@ -463,8 +470,9 @@ private void ExecuteTemplates(BlazorIdentityModel templateModel)
463470
var templatedString = templateInvoker.InvokeTemplate(contextTemplate, dictParams);
464471
if (!string.IsNullOrEmpty(templatedString))
465472
{
466-
//currently only Identity...cs files are of CSharp type, rest are razor
467-
string extension = templateName.StartsWith("identity", StringComparison.OrdinalIgnoreCase) ? ".cs" : ".razor";
473+
// Files in Pages and Shared folders are Razor components, others are C# files
474+
string extension = templateName.StartsWith("Pages", StringComparison.OrdinalIgnoreCase) ||
475+
templateName.StartsWith("Shared", StringComparison.OrdinalIgnoreCase) ? ".razor" : ".cs";
468476
string templateNameWithNamespace = $"{templateModel.BlazorIdentityNamespace}.{templateName}";
469477
string templatePath = StringUtil.ToPath(templateNameWithNamespace, templateModel.BaseOutputPath, ProjectContext.RootNamespace);
470478
string templatedFilePath = $"{templatePath}{extension}";
@@ -546,6 +554,31 @@ private void ExecuteDbContextTemplate(IdentityDbContextModel model)
546554
}
547555
}
548556

557+
private void AddStaticFiles(BlazorIdentityModel templateModel)
558+
{
559+
var identityComponentsAccountPath = Path.Combine(templateModel.BaseOutputPath, "Components", "Account");
560+
if (!FileSystem.DirectoryExists(identityComponentsAccountPath))
561+
{
562+
FileSystem.CreateDirectory(identityComponentsAccountPath);
563+
}
564+
565+
foreach (var (templateFolder, sourceFilePath) in AllBlazorIdentityStaticFiles)
566+
{
567+
var relativeFilePath = Path.GetRelativePath(templateFolder, sourceFilePath);
568+
var destinationFilePath = Path.Combine(identityComponentsAccountPath, relativeFilePath);
569+
570+
var folderName = Path.GetDirectoryName(destinationFilePath);
571+
if (!FileSystem.DirectoryExists(folderName))
572+
{
573+
FileSystem.CreateDirectory(folderName);
574+
}
575+
576+
var fileContent = FileSystem.ReadAllText(sourceFilePath);
577+
FileSystem.WriteAllText(destinationFilePath, fileContent);
578+
Logger.LogMessage($"Added static file : {destinationFilePath}");
579+
}
580+
}
581+
549582
private ITextTransformation GetBlazorIdentityTransformation(string templateName)
550583
{
551584
if (string.IsNullOrEmpty(templateName))

src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/BlazorIdentityHelper.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ internal static class BlazorIdentityHelper
1919
internal const string GetConnectionString = nameof(GetConnectionString);
2020
internal const string UseSqlite = nameof(UseSqlite);
2121
internal const string UseSqlServer = nameof(UseSqlServer);
22+
23+
private static readonly HashSet<string> _staticFileExtensions = [".js"];
24+
2225
internal static string GetFormattedRelativeIdentityFile(string fullFileName)
2326
{
2427
string identifier = "BlazorIdentity\\";
@@ -56,8 +59,8 @@ internal static string EditIdentityStrings(string stringToModify, string dbConte
5659
}
5760
if (stringToModify.Contains(GetConnectionString))
5861
{
59-
modifiedString = modifiedString.Replace("GetConnectionString(\"{0}\")", $"GetConnectionString(\"{dbContextClassName}Connection\")");
60-
modifiedString = modifiedString.Replace("Connection string '{0}'", $"Connection string '{dbContextClassName}Connection'");
62+
modifiedString = modifiedString.Replace("GetConnectionString(\"{0}\")", $"GetConnectionString(\"{dbContextClassName}\")");
63+
modifiedString = modifiedString.Replace("Connection string '{0}'", $"Connection string '{dbContextClassName}'");
6164
}
6265

6366
return modifiedString;
@@ -74,6 +77,20 @@ internal static IEnumerable<string> GetGeneralT4Files(IFileSystem fileSystem, IE
7477
return null;
7578
}
7679

80+
internal static IEnumerable<(string TemplateFolder, string FullPath)> GetBlazorIdentityStaticFiles(IFileSystem fileSystem, IEnumerable<string> templateFolders)
81+
{
82+
var blazorIdentityTemplateFolder = templateFolders.FirstOrDefault(x => x.Contains("BlazorIdentity", StringComparison.OrdinalIgnoreCase));
83+
if (!string.IsNullOrEmpty(blazorIdentityTemplateFolder) && fileSystem.DirectoryExists(blazorIdentityTemplateFolder))
84+
{
85+
var allFiles = fileSystem.EnumerateFiles(blazorIdentityTemplateFolder, "*.*", SearchOption.AllDirectories);
86+
return allFiles
87+
.Where(f => _staticFileExtensions.Contains(Path.GetExtension(f)))
88+
.Select(f => (blazorIdentityTemplateFolder, f));
89+
}
90+
91+
return null;
92+
}
93+
7794
/// <summary>
7895
/// returning full file paths (.tt) for all blazor identity templates
7996
/// TODO throw exception if nothing found, can't really scaffold is no files were found

src/Scaffolding/VS.Web.CG.Mvc/BlazorIdentity/blazorIdentityChanges.json

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,6 @@
1919
"Newline": true
2020
}
2121
},
22-
{
23-
"InsertBefore": [ "var app = WebApplication.CreateBuilder.Build();" ],
24-
"Block": "WebApplication.CreateBuilder.Services.AddScoped<IdentityUserAccessor>()",
25-
"LeadingTrivia": {
26-
"Newline": true
27-
}
28-
},
2922
{
3023
"InsertBefore": [ "var app = WebApplication.CreateBuilder.Build();" ],
3124
"Block": "WebApplication.CreateBuilder.Services.AddScoped<IdentityRedirectManager>()",
@@ -71,7 +64,11 @@
7164
"InsertBefore": [ "var app = WebApplication.CreateBuilder.Build();" ],
7265
"CheckBlock" : "builder.Services.AddIdentityCore",
7366
"MultiLineBlock": [
74-
"builder.Services.AddIdentityCore<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)",
67+
"builder.Services.AddIdentityCore<ApplicationUser>(options =>",
68+
" {",
69+
" options.SignIn.RequireConfirmedAccount = true;",
70+
" options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;",
71+
" })",
7572
" .AddEntityFrameworkStores<ApplicationDbContext>()",
7673
" .AddSignInManager()",
7774
" .AddDefaultTokenProviders()"
@@ -247,6 +244,21 @@
247244
"CheckBlock": "Microsoft.AspNetCore.Components.Authorization"
248245
}
249246
]
247+
},
248+
{
249+
"FileName": "Components\\App.razor",
250+
"Replacements": [
251+
{
252+
"ReplaceSnippet": [
253+
"</body>"
254+
],
255+
"MultiLineBlock": [
256+
" <script src=\"@Assets[\"Components/Account/Shared/PasskeySubmit.razor.js\"]\" type=\"module\"></script>",
257+
"</body>"
258+
],
259+
"CheckBlock": "PasskeySubmit.razor.js"
260+
}
261+
]
250262
}
251263
]
252-
}
264+
}

src/Scaffolding/VS.Web.CG.Mvc/Templates/Blazor/Delete.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ public virtual string TransformText()
102102
this.Write(this.ToStringHelper.ToStringWithCulture(primaryKeyName));
103103
this.Write(");\r\n\r\n if (");
104104
this.Write(this.ToStringHelper.ToStringWithCulture(modelNameLowerInv));
105-
this.Write(" is null)\r\n {\r\n NavigationManager.NotFound();\r\n " +
106-
" }\r\n }\r\n\r\n private async Task Delete");
105+
this.Write(" is null)\r\n {\r\n NavigationManager.NotFound();\r\n }\r\n }" +
106+
"\r\n\r\n private async Task Delete");
107107
this.Write(this.ToStringHelper.ToStringWithCulture(modelName));
108108
this.Write("()\r\n {\r\n using var context = DbFactory.CreateDbContext();\r\n cont" +
109109
"ext.");

src/Scaffolding/VS.Web.CG.Mvc/Templates/Blazor/Details.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ public virtual string TransformText()
102102
this.Write(this.ToStringHelper.ToStringWithCulture(primaryKeyName));
103103
this.Write(");\r\n\r\n if (");
104104
this.Write(this.ToStringHelper.ToStringWithCulture(modelNameLowerInv));
105-
this.Write(" is null)\r\n {\r\n NavigationManager.NotFound();\r\n " +
106-
" }\r\n }\r\n}\r\n");
105+
this.Write(" is null)\r\n {\r\n NavigationManager.NotFound();\r\n }\r\n }" +
106+
"\r\n}\r\n");
107107
return this.GenerationEnvironment.ToString();
108108
}
109109
private global::Microsoft.VisualStudio.TextTemplating.ITextTemplatingEngineHost hostValue;

src/Scaffolding/VS.Web.CG.Mvc/Templates/Blazor/Edit.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,9 @@ public virtual string TransformText()
155155
this.Write(this.ToStringHelper.ToStringWithCulture(modelName));
156156
this.Write("!.");
157157
this.Write(this.ToStringHelper.ToStringWithCulture(primaryKeyName));
158-
this.Write("))\r\n {\r\n NavigationManager.NotFound();\r\n " +
159-
" }\r\n else\r\n {\r\n throw;\r\n " +
160-
" }\r\n }\r\n\r\n NavigationManager.NavigateTo(\"/");
158+
this.Write("))\r\n {\r\n NavigationManager.NotFound();\r\n }\r\n" +
159+
" else\r\n {\r\n throw;\r\n }\r\n " +
160+
"}\r\n\r\n NavigationManager.NavigateTo(\"/");
161161
this.Write(this.ToStringHelper.ToStringWithCulture(pluralModelLowerInv));
162162
this.Write("\");\r\n }\r\n\r\n private bool ");
163163
this.Write(this.ToStringHelper.ToStringWithCulture(modelName));

src/Scaffolding/VS.Web.CG.Mvc/Templates/BlazorIdentity/IdentityComponentsEndpointRouteBuilderExtensions.Interfaces.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,3 @@ public partial class IdentityComponentsEndpointRouteBuilderExtensions : ITextTra
66
{
77
}
88
}
9-

src/Scaffolding/VS.Web.CG.Mvc/Templates/BlazorIdentity/IdentityComponentsEndpointRouteBuilderExtensions.cs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public virtual string TransformText()
3434
$"{Model.BlazorIdentityNamespace}.Pages",
3535
$"{Model.BlazorIdentityNamespace}.Pages.Manage",
3636
"Microsoft.AspNetCore.Authentication",
37+
"Microsoft.AspNetCore.Antiforgery",
3738
"Microsoft.AspNetCore.Components.Authorization",
3839
"Microsoft.AspNetCore.Http.Extensions",
3940
"Microsoft.AspNetCore.Identity",
@@ -83,7 +84,7 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn
8384
8485
accountGroup.MapPost(""/Logout"", async (
8586
ClaimsPrincipal user,
86-
SignInManager<");
87+
[FromServices] SignInManager<");
8788
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
8889
this.Write(@"> signInManager,
8990
[FromForm] string returnUrl) =>
@@ -92,6 +93,51 @@ public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEn
9293
return TypedResults.LocalRedirect($""~/{returnUrl}"");
9394
});
9495
96+
accountGroup.MapPost(""/PasskeyCreationOptions"", async (
97+
HttpContext context,
98+
[FromServices] UserManager<");
99+
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
100+
this.Write("> userManager,\r\n [FromServices] SignInManager<");
101+
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
102+
this.Write(@"> signInManager,
103+
[FromServices] IAntiforgery antiforgery) =>
104+
{
105+
await antiforgery.ValidateRequestAsync(context);
106+
107+
var user = await userManager.GetUserAsync(context.User);
108+
if (user is null)
109+
{
110+
return Results.NotFound($""Unable to load user with ID '{userManager.GetUserId(context.User)}'."");
111+
}
112+
113+
var userId = await userManager.GetUserIdAsync(user);
114+
var userName = await userManager.GetUserNameAsync(user) ?? ""User"";
115+
var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
116+
{
117+
Id = userId,
118+
Name = userName,
119+
DisplayName = userName
120+
});
121+
return TypedResults.Content(optionsJson, contentType: ""application/json"");
122+
});
123+
124+
accountGroup.MapPost(""/PasskeyRequestOptions"", async (
125+
HttpContext context,
126+
[FromServices] UserManager<");
127+
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
128+
this.Write("> userManager,\r\n [FromServices] SignInManager<");
129+
this.Write(this.ToStringHelper.ToStringWithCulture(Model.UserClassName));
130+
this.Write(@"> signInManager,
131+
[FromServices] IAntiforgery antiforgery,
132+
[FromQuery] string? username) =>
133+
{
134+
await antiforgery.ValidateRequestAsync(context);
135+
136+
var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
137+
var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
138+
return TypedResults.Content(optionsJson, contentType: ""application/json"");
139+
});
140+
95141
var manageGroup = accountGroup.MapGroup(""/Manage"").RequireAuthorization();
96142
97143
manageGroup.MapPost(""/LinkExternalLogin"", async (

src/Scaffolding/VS.Web.CG.Mvc/Templates/BlazorIdentity/IdentityComponentsEndpointRouteBuilderExtensions.tt

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ using System.Text.Json;
1313
$"{Model.BlazorIdentityNamespace}.Pages",
1414
$"{Model.BlazorIdentityNamespace}.Pages.Manage",
1515
"Microsoft.AspNetCore.Authentication",
16+
"Microsoft.AspNetCore.Antiforgery",
1617
"Microsoft.AspNetCore.Components.Authorization",
1718
"Microsoft.AspNetCore.Http.Extensions",
1819
"Microsoft.AspNetCore.Identity",
@@ -58,13 +59,52 @@ namespace Microsoft.AspNetCore.Routing
5859

5960
accountGroup.MapPost("/Logout", async (
6061
ClaimsPrincipal user,
61-
SignInManager<<#= Model.UserClassName #>> signInManager,
62+
[FromServices] SignInManager<<#= Model.UserClassName #>> signInManager,
6263
[FromForm] string returnUrl) =>
6364
{
6465
await signInManager.SignOutAsync();
6566
return TypedResults.LocalRedirect($"~/{returnUrl}");
6667
});
6768

69+
accountGroup.MapPost("/PasskeyCreationOptions", async (
70+
HttpContext context,
71+
[FromServices] UserManager<<#= Model.UserClassName #>> userManager,
72+
[FromServices] SignInManager<<#= Model.UserClassName #>> signInManager,
73+
[FromServices] IAntiforgery antiforgery) =>
74+
{
75+
await antiforgery.ValidateRequestAsync(context);
76+
77+
var user = await userManager.GetUserAsync(context.User);
78+
if (user is null)
79+
{
80+
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
81+
}
82+
83+
var userId = await userManager.GetUserIdAsync(user);
84+
var userName = await userManager.GetUserNameAsync(user) ?? "User";
85+
var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
86+
{
87+
Id = userId,
88+
Name = userName,
89+
DisplayName = userName
90+
});
91+
return TypedResults.Content(optionsJson, contentType: "application/json");
92+
});
93+
94+
accountGroup.MapPost("/PasskeyRequestOptions", async (
95+
HttpContext context,
96+
[FromServices] UserManager<<#= Model.UserClassName #>> userManager,
97+
[FromServices] SignInManager<<#= Model.UserClassName #>> signInManager,
98+
[FromServices] IAntiforgery antiforgery,
99+
[FromQuery] string? username) =>
100+
{
101+
await antiforgery.ValidateRequestAsync(context);
102+
103+
var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
104+
var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
105+
return TypedResults.Content(optionsJson, contentType: "application/json");
106+
});
107+
68108
var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();
69109

70110
manageGroup.MapPost("/LinkExternalLogin", async (

src/Scaffolding/VS.Web.CG.Mvc/Templates/BlazorIdentity/IdentityNoOpEmailSender.Interfaces.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,3 @@ public partial class IdentityNoOpEmailSender : ITextTransformation
66
{
77
}
88
}
9-

0 commit comments

Comments
 (0)