From 6eacd4c4805676eff6a70cd8e6e7a64f70944681 Mon Sep 17 00:00:00 2001 From: mcmuzzle Date: Wed, 4 Sep 2024 22:50:00 +0200 Subject: [PATCH] changement du template pour inclure l'authentification --- .gitea/workflows/merge.yaml | 34 +++ README.md => README.adoc | 0 documentation/index.adoc | 1 + src/LudikZone.sln | 10 +- ...omponentsEndpointRouteBuilderExtensions.cs | 112 ++++++++ .../Account/IdentityNoOpEmailSender.cs | 20 ++ .../Account/IdentityRedirectManager.cs | 58 ++++ ...RevalidatingAuthenticationStateProvider.cs | 47 +++ .../Account/IdentityUserAccessor.cs | 19 ++ .../Account/Pages/AccessDenied.razor | 5 + .../Account/Pages/ConfirmEmail.razor | 48 ++++ .../Account/Pages/ConfirmEmailChange.razor | 68 +++++ .../Account/Pages/ExternalLogin.razor | 195 +++++++++++++ .../Account/Pages/ForgotPassword.razor | 68 +++++ .../Pages/ForgotPasswordConfirmation.razor | 7 + .../Account/Pages/InvalidPasswordReset.razor | 8 + .../Account/Pages/InvalidUser.razor | 7 + .../Components/Account/Pages/Lockout.razor | 8 + .../Components/Account/Pages/Login.razor | 180 ++++++++++++ .../Account/Pages/LoginWith2fa.razor | 101 +++++++ .../Account/Pages/LoginWithRecoveryCode.razor | 132 +++++++++ .../Account/Pages/Manage/ChangePassword.razor | 98 +++++++ .../Pages/Manage/DeletePersonalData.razor | 86 ++++++ .../Account/Pages/Manage/Disable2fa.razor | 64 +++++ .../Account/Pages/Manage/Email.razor | 122 ++++++++ .../Pages/Manage/EnableAuthenticator.razor | 178 ++++++++++++ .../Account/Pages/Manage/ExternalLogins.razor | 140 +++++++++ .../Pages/Manage/GenerateRecoveryCodes.razor | 68 +++++ .../Account/Pages/Manage/Index.razor | 77 +++++ .../Account/Pages/Manage/PersonalData.razor | 41 +++ .../Pages/Manage/ResetAuthenticator.razor | 52 ++++ .../Account/Pages/Manage/SetPassword.razor | 87 ++++++ .../Manage/TwoFactorAuthentication.razor | 110 +++++++ .../Account/Pages/Manage/_Imports.razor | 2 + .../Components/Account/Pages/Register.razor | 232 +++++++++++++++ .../Account/Pages/RegisterConfirmation.razor | 106 +++++++ .../Pages/ResendEmailConfirmation.razor | 100 +++++++ .../Account/Pages/ResetPassword.razor | 159 +++++++++++ .../Pages/ResetPasswordConfirmation.razor | 7 + .../Components/Account/Pages/_Imports.razor | 3 + .../Account/Shared/AccountLayout.razor | 43 +++ .../Account/Shared/ExternalLoginPicker.razor | 44 +++ .../Account/Shared/ManageLayout.razor | 14 + .../Account/Shared/ManageNavMenu.razor | 25 ++ .../Account/Shared/RedirectToLogin.razor | 8 + .../Account/Shared/ShowRecoveryCodes.razor | 28 ++ .../Account/Shared/StatusMessage.razor | 28 ++ src/LudikZoneBlazor/Components/App.razor | 14 +- .../Components/Layout/MainLayout.razor | 67 +---- .../Components/Layout/NavMenu.razor | 44 ++- .../Components/Pages/Auth.razor | 14 + src/LudikZoneBlazor/Components/Routes.razor | 9 +- src/LudikZoneBlazor/Components/_Imports.razor | 2 + .../Data/ApplicationDbContext.cs | 8 + src/LudikZoneBlazor/Data/ApplicationUser.cs | 9 + ...000000000_CreateIdentitySchema.Designer.cs | 268 ++++++++++++++++++ .../00000000000000_CreateIdentitySchema.cs | 222 +++++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 265 +++++++++++++++++ src/LudikZoneBlazor/Data/app.db | Bin 0 -> 102400 bytes src/LudikZoneBlazor/LudikZoneBlazor.csproj | 11 + src/LudikZoneBlazor/Program.cs | 38 ++- .../Properties/launchSettings.json | 8 +- src/LudikZoneBlazor/appsettings.json | 19 +- 63 files changed, 3959 insertions(+), 89 deletions(-) create mode 100644 .gitea/workflows/merge.yaml rename README.md => README.adoc (100%) create mode 100644 documentation/index.adoc create mode 100644 src/LudikZoneBlazor/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs create mode 100644 src/LudikZoneBlazor/Components/Account/IdentityNoOpEmailSender.cs create mode 100644 src/LudikZoneBlazor/Components/Account/IdentityRedirectManager.cs create mode 100644 src/LudikZoneBlazor/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs create mode 100644 src/LudikZoneBlazor/Components/Account/IdentityUserAccessor.cs create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/AccessDenied.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/ConfirmEmail.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/ConfirmEmailChange.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/ExternalLogin.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/ForgotPassword.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/ForgotPasswordConfirmation.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/InvalidPasswordReset.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/InvalidUser.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Lockout.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Login.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/LoginWith2fa.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/LoginWithRecoveryCode.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/ChangePassword.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/DeletePersonalData.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/Disable2fa.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/Email.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/EnableAuthenticator.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/ExternalLogins.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/Index.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/PersonalData.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/ResetAuthenticator.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/SetPassword.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/TwoFactorAuthentication.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Manage/_Imports.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/Register.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/RegisterConfirmation.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/ResendEmailConfirmation.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/ResetPassword.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/ResetPasswordConfirmation.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Pages/_Imports.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Shared/AccountLayout.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Shared/ExternalLoginPicker.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Shared/ManageLayout.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Shared/ManageNavMenu.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Shared/RedirectToLogin.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Shared/ShowRecoveryCodes.razor create mode 100644 src/LudikZoneBlazor/Components/Account/Shared/StatusMessage.razor create mode 100644 src/LudikZoneBlazor/Components/Pages/Auth.razor create mode 100644 src/LudikZoneBlazor/Data/ApplicationDbContext.cs create mode 100644 src/LudikZoneBlazor/Data/ApplicationUser.cs create mode 100644 src/LudikZoneBlazor/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs create mode 100644 src/LudikZoneBlazor/Data/Migrations/00000000000000_CreateIdentitySchema.cs create mode 100644 src/LudikZoneBlazor/Data/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 src/LudikZoneBlazor/Data/app.db diff --git a/.gitea/workflows/merge.yaml b/.gitea/workflows/merge.yaml new file mode 100644 index 0000000..407c331 --- /dev/null +++ b/.gitea/workflows/merge.yaml @@ -0,0 +1,34 @@ +name: "Main Build Process" + +# Runs on main branch commits, +# every commit in a pull request, any published release. +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + release: + types: [published] + +jobs: + asciidoc: + name: "generate documentation" + + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Get build container + id: adocbuild + uses: tonynv/asciidoctor-action@master + with: + program: "asciidoctor -D docs --backend=html5 -o index.html documentation/index.adoc" + - name: Print execution time + run: echo "Time ${{ steps.adocbuild.outputs.time }}" + - name: Deploy docs to ghpages + uses: actions/upload-artifact@v3 + with: + name: Doc + path: ./docs + retention-days: 5 diff --git a/README.md b/README.adoc similarity index 100% rename from README.md rename to README.adoc diff --git a/documentation/index.adoc b/documentation/index.adoc new file mode 100644 index 0000000..70c379b --- /dev/null +++ b/documentation/index.adoc @@ -0,0 +1 @@ +Hello world \ No newline at end of file diff --git a/src/LudikZone.sln b/src/LudikZone.sln index 0a606bb..0950581 100644 --- a/src/LudikZone.sln +++ b/src/LudikZone.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LudikZoneBlazor", "LudikZoneBlazor\LudikZoneBlazor.csproj", "{169488B5-AE88-41C0-8EFE-658E5A259EDB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LudikZoneBlazor", "LudikZoneBlazor\LudikZoneBlazor.csproj", "{79AF398A-0AC3-425C-BA51-B6CFBEECAC28}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -14,9 +14,9 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {169488B5-AE88-41C0-8EFE-658E5A259EDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {169488B5-AE88-41C0-8EFE-658E5A259EDB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {169488B5-AE88-41C0-8EFE-658E5A259EDB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {169488B5-AE88-41C0-8EFE-658E5A259EDB}.Release|Any CPU.Build.0 = Release|Any CPU + {79AF398A-0AC3-425C-BA51-B6CFBEECAC28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79AF398A-0AC3-425C-BA51-B6CFBEECAC28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79AF398A-0AC3-425C-BA51-B6CFBEECAC28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79AF398A-0AC3-425C-BA51-B6CFBEECAC28}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/LudikZoneBlazor/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/src/LudikZoneBlazor/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..4246f4e --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -0,0 +1,112 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using LudikZoneBlazor.Components.Account.Pages; +using LudikZoneBlazor.Components.Account.Pages.Manage; +using LudikZoneBlazor.Data; + +namespace Microsoft.AspNetCore.Routing; + +internal static class IdentityComponentsEndpointRouteBuilderExtensions +{ + // These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project. + public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var accountGroup = endpoints.MapGroup("/Account"); + + accountGroup.MapPost("/PerformExternalLogin", ( + HttpContext context, + [FromServices] SignInManager signInManager, + [FromForm] string provider, + [FromForm] string returnUrl) => + { + IEnumerable> query = [ + new("ReturnUrl", returnUrl), + new("Action", ExternalLogin.LoginCallbackAction)]; + + var redirectUrl = UriHelper.BuildRelative( + context.Request.PathBase, + "/Account/ExternalLogin", + QueryString.Create(query)); + + var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); + return TypedResults.Challenge(properties, [provider]); + }); + + accountGroup.MapPost("/Logout", async ( + ClaimsPrincipal user, + SignInManager signInManager, + [FromForm] string returnUrl) => + { + await signInManager.SignOutAsync(); + return TypedResults.LocalRedirect($"~/{returnUrl}"); + }); + + var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization(); + + manageGroup.MapPost("/LinkExternalLogin", async ( + HttpContext context, + [FromServices] SignInManager signInManager, + [FromForm] string provider) => + { + // Clear the existing external cookie to ensure a clean login process + await context.SignOutAsync(IdentityConstants.ExternalScheme); + + var redirectUrl = UriHelper.BuildRelative( + context.Request.PathBase, + "/Account/Manage/ExternalLogins", + QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction)); + + var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User)); + return TypedResults.Challenge(properties, [provider]); + }); + + var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); + var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData"); + + manageGroup.MapPost("/DownloadPersonalData", async ( + HttpContext context, + [FromServices] UserManager userManager, + [FromServices] AuthenticationStateProvider authenticationStateProvider) => + { + var user = await userManager.GetUserAsync(context.User); + if (user is null) + { + return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'."); + } + + var userId = await userManager.GetUserIdAsync(user); + downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId); + + // Only include personal data for download + var personalData = new Dictionary(); + var personalDataProps = typeof(ApplicationUser).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute))); + foreach (var p in personalDataProps) + { + personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null"); + } + + var logins = await userManager.GetLoginsAsync(user); + foreach (var l in logins) + { + personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey); + } + + personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!); + var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData); + + context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json"); + return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json"); + }); + + return accountGroup; + } +} diff --git a/src/LudikZoneBlazor/Components/Account/IdentityNoOpEmailSender.cs b/src/LudikZoneBlazor/Components/Account/IdentityNoOpEmailSender.cs new file mode 100644 index 0000000..781113b --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/IdentityNoOpEmailSender.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using LudikZoneBlazor.Data; + +namespace LudikZoneBlazor.Components.Account; + +// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation. +internal sealed class IdentityNoOpEmailSender : IEmailSender +{ + private readonly IEmailSender emailSender = new NoOpEmailSender(); + + public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) => + emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here."); + + public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => + emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); + + public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => + emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); +} diff --git a/src/LudikZoneBlazor/Components/Account/IdentityRedirectManager.cs b/src/LudikZoneBlazor/Components/Account/IdentityRedirectManager.cs new file mode 100644 index 0000000..44f6083 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/IdentityRedirectManager.cs @@ -0,0 +1,58 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; + +namespace LudikZoneBlazor.Components.Account; + +internal sealed class IdentityRedirectManager(NavigationManager navigationManager) +{ + public const string StatusCookieName = "Identity.StatusMessage"; + + private static readonly CookieBuilder StatusCookieBuilder = new() + { + SameSite = SameSiteMode.Strict, + HttpOnly = true, + IsEssential = true, + MaxAge = TimeSpan.FromSeconds(5), + }; + + [DoesNotReturn] + public void RedirectTo(string? uri) + { + uri ??= ""; + + // Prevent open redirects. + if (!Uri.IsWellFormedUriString(uri, UriKind.Relative)) + { + uri = navigationManager.ToBaseRelativePath(uri); + } + + // During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect. + // So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown. + navigationManager.NavigateTo(uri); + throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering."); + } + + [DoesNotReturn] + public void RedirectTo(string uri, Dictionary queryParameters) + { + var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path); + var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters); + RedirectTo(newUri); + } + + [DoesNotReturn] + public void RedirectToWithStatus(string uri, string message, HttpContext context) + { + context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context)); + RedirectTo(uri); + } + + private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path); + + [DoesNotReturn] + public void RedirectToCurrentPage() => RedirectTo(CurrentPath); + + [DoesNotReturn] + public void RedirectToCurrentPageWithStatus(string message, HttpContext context) + => RedirectToWithStatus(CurrentPath, message, context); +} diff --git a/src/LudikZoneBlazor/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs b/src/LudikZoneBlazor/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs new file mode 100644 index 0000000..74b2a30 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using LudikZoneBlazor.Data; + +namespace LudikZoneBlazor.Components.Account; + +// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user +// every 30 minutes an interactive circuit is connected. +internal sealed class IdentityRevalidatingAuthenticationStateProvider( + ILoggerFactory loggerFactory, + IServiceScopeFactory scopeFactory, + IOptions options) + : RevalidatingServerAuthenticationStateProvider(loggerFactory) +{ + protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); + + protected override async Task ValidateAuthenticationStateAsync( + AuthenticationState authenticationState, CancellationToken cancellationToken) + { + // Get the user manager from a new scope to ensure it fetches fresh data + await using var scope = scopeFactory.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + return await ValidateSecurityStampAsync(userManager, authenticationState.User); + } + + private async Task ValidateSecurityStampAsync(UserManager userManager, ClaimsPrincipal principal) + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + { + return false; + } + else if (!userManager.SupportsUserSecurityStamp) + { + return true; + } + else + { + var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType); + var userStamp = await userManager.GetSecurityStampAsync(user); + return principalStamp == userStamp; + } + } +} diff --git a/src/LudikZoneBlazor/Components/Account/IdentityUserAccessor.cs b/src/LudikZoneBlazor/Components/Account/IdentityUserAccessor.cs new file mode 100644 index 0000000..c27faf9 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/IdentityUserAccessor.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Identity; +using LudikZoneBlazor.Data; + +namespace LudikZoneBlazor.Components.Account; + +internal sealed class IdentityUserAccessor(UserManager userManager, IdentityRedirectManager redirectManager) +{ + public async Task GetRequiredUserAsync(HttpContext context) + { + var user = await userManager.GetUserAsync(context.User); + + if (user is null) + { + redirectManager.RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context); + } + + return user; + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/AccessDenied.razor b/src/LudikZoneBlazor/Components/Account/Pages/AccessDenied.razor new file mode 100644 index 0000000..db67a6f --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/AccessDenied.razor @@ -0,0 +1,5 @@ +@page "/Account/AccessDenied" + +Access denied + +You do not have access to this resource. diff --git a/src/LudikZoneBlazor/Components/Account/Pages/ConfirmEmail.razor b/src/LudikZoneBlazor/Components/Account/Pages/ConfirmEmail.razor new file mode 100644 index 0000000..0ab88dc --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/ConfirmEmail.razor @@ -0,0 +1,48 @@ +@page "/Account/ConfirmEmail" + +@using System.Text +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager + +Confirm email + +

Confirm email

+ + +@code { + private string? statusMessage; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? UserId { get; set; } + + [SupplyParameterFromQuery] + private string? Code { get; set; } + + protected override async Task OnInitializedAsync() + { + if (UserId is null || Code is null) + { + RedirectManager.RedirectTo(""); + } + + var user = await UserManager.FindByIdAsync(UserId); + if (user is null) + { + HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + statusMessage = $"Error loading user with ID {UserId}"; + } + else + { + var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); + var result = await UserManager.ConfirmEmailAsync(user, code); + statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email."; + } + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/ConfirmEmailChange.razor b/src/LudikZoneBlazor/Components/Account/Pages/ConfirmEmailChange.razor new file mode 100644 index 0000000..942c302 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/ConfirmEmailChange.razor @@ -0,0 +1,68 @@ +@page "/Account/ConfirmEmailChange" + +@using System.Text +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +Confirm email change + +

Confirm email change

+ + + +@code { + private string? message; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromQuery] + private string? UserId { get; set; } + + [SupplyParameterFromQuery] + private string? Email { get; set; } + + [SupplyParameterFromQuery] + private string? Code { get; set; } + + protected override async Task OnInitializedAsync() + { + if (UserId is null || Email is null || Code is null) + { + RedirectManager.RedirectToWithStatus( + "Account/Login", "Error: Invalid email change confirmation link.", HttpContext); + } + + var user = await UserManager.FindByIdAsync(UserId); + if (user is null) + { + message = "Unable to find user with Id '{userId}'"; + return; + } + + var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); + var result = await UserManager.ChangeEmailAsync(user, Email, code); + if (!result.Succeeded) + { + message = "Error changing email."; + return; + } + + // In our UI email and user name are one and the same, so when we update the email + // we need to update the user name. + var setUserNameResult = await UserManager.SetUserNameAsync(user, Email); + if (!setUserNameResult.Succeeded) + { + message = "Error changing user name."; + return; + } + + await SignInManager.RefreshSignInAsync(user); + message = "Thank you for confirming your email change."; + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/ExternalLogin.razor b/src/LudikZoneBlazor/Components/Account/Pages/ExternalLogin.razor new file mode 100644 index 0000000..2741582 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/ExternalLogin.razor @@ -0,0 +1,195 @@ +@page "/Account/ExternalLogin" + +@using System.ComponentModel.DataAnnotations +@using System.Security.Claims +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using LudikZoneBlazor.Data + +@inject SignInManager SignInManager +@inject UserManager UserManager +@inject IUserStore UserStore +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Register + + +

Register

+

Associate your @ProviderDisplayName account.

+ + +
+ You've successfully authenticated with @ProviderDisplayName. + Please enter an email address for this site below and click the Register button to finish + logging in. +
+ +
+
+ + + +
+ + + +
+ +
+
+
+ +@code { + public const string LoginCallbackAction = "LoginCallback"; + + private string? message; + private ExternalLoginInfo externalLoginInfo = default!; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + [SupplyParameterFromQuery] + private string? RemoteError { get; set; } + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + [SupplyParameterFromQuery] + private string? Action { get; set; } + + private string? ProviderDisplayName => externalLoginInfo.ProviderDisplayName; + + protected override async Task OnInitializedAsync() + { + if (RemoteError is not null) + { + RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext); + } + + var info = await SignInManager.GetExternalLoginInfoAsync(); + if (info is null) + { + RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext); + } + + externalLoginInfo = info; + + if (HttpMethods.IsGet(HttpContext.Request.Method)) + { + if (Action == LoginCallbackAction) + { + await OnLoginCallbackAsync(); + return; + } + + // We should only reach this page via the login callback, so redirect back to + // the login page if we get here some other way. + RedirectManager.RedirectTo("Account/Login"); + } + } + + private async Task OnLoginCallbackAsync() + { + // Sign in the user with this external login provider if the user already has a login. + var result = await SignInManager.ExternalLoginSignInAsync( + externalLoginInfo.LoginProvider, + externalLoginInfo.ProviderKey, + isPersistent: false, + bypassTwoFactor: true); + + if (result.Succeeded) + { + Logger.LogInformation( + "{Name} logged in with {LoginProvider} provider.", + externalLoginInfo.Principal.Identity?.Name, + externalLoginInfo.LoginProvider); + RedirectManager.RedirectTo(ReturnUrl); + } + else if (result.IsLockedOut) + { + RedirectManager.RedirectTo("Account/Lockout"); + } + + // If the user does not have an account, then ask the user to create an account. + if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email)) + { + Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? ""; + } + } + + private async Task OnValidSubmitAsync() + { + var emailStore = GetEmailStore(); + var user = CreateUser(); + + await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None); + await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None); + + var result = await UserManager.CreateAsync(user); + if (result.Succeeded) + { + result = await UserManager.AddLoginAsync(user, externalLoginInfo); + if (result.Succeeded) + { + Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider); + + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["code"] = code }); + await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); + + // 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) + { + RedirectManager.RedirectTo("Account/RegisterConfirmation", new() { ["email"] = Input.Email }); + } + + await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider); + RedirectManager.RedirectTo(ReturnUrl); + } + } + + message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}"; + } + + private ApplicationUser CreateUser() + { + try + { + return Activator.CreateInstance(); + } + catch + { + throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " + + $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor"); + } + } + + private IUserEmailStore GetEmailStore() + { + if (!UserManager.SupportsUserEmail) + { + throw new NotSupportedException("The default UI requires a user store with email support."); + } + return (IUserEmailStore)UserStore; + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/ForgotPassword.razor b/src/LudikZoneBlazor/Components/Account/Pages/ForgotPassword.razor new file mode 100644 index 0000000..0e9f454 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/ForgotPassword.razor @@ -0,0 +1,68 @@ +@page "/Account/ForgotPassword" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Forgot your password? + +Forgot your password? + +Enter your email. + + + + + + + + + + Reset password + + + + +@code { + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + private async Task OnValidSubmitAsync() + { + var user = await UserManager.FindByEmailAsync(Input.Email); + if (user is null || !(await UserManager.IsEmailConfirmedAsync(user))) + { + // Don't reveal that the user does not exist or is not confirmed + RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); + } + + // For more information on how to enable account confirmation and password reset please + // visit https://go.microsoft.com/fwlink/?LinkID=532713 + var code = await UserManager.GeneratePasswordResetTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri, + new Dictionary { ["code"] = code }); + + await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); + + RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation"); + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/ForgotPasswordConfirmation.razor b/src/LudikZoneBlazor/Components/Account/Pages/ForgotPasswordConfirmation.razor new file mode 100644 index 0000000..31f70ce --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/ForgotPasswordConfirmation.razor @@ -0,0 +1,7 @@ +@page "/Account/ForgotPasswordConfirmation" + +Forgot password confirmation + +Forgot password confirmation + +Please check your email to reset your password. diff --git a/src/LudikZoneBlazor/Components/Account/Pages/InvalidPasswordReset.razor b/src/LudikZoneBlazor/Components/Account/Pages/InvalidPasswordReset.razor new file mode 100644 index 0000000..509578b --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/InvalidPasswordReset.razor @@ -0,0 +1,8 @@ +@page "/Account/InvalidPasswordReset" + +Invalid password reset + +

Invalid password reset

+

+ The password reset link is invalid. +

diff --git a/src/LudikZoneBlazor/Components/Account/Pages/InvalidUser.razor b/src/LudikZoneBlazor/Components/Account/Pages/InvalidUser.razor new file mode 100644 index 0000000..e61fe5d --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/InvalidUser.razor @@ -0,0 +1,7 @@ +@page "/Account/InvalidUser" + +Invalid user + +

Invalid user

+ + diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Lockout.razor b/src/LudikZoneBlazor/Components/Account/Pages/Lockout.razor new file mode 100644 index 0000000..a8d1e0a --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Lockout.razor @@ -0,0 +1,8 @@ +@page "/Account/Lockout" + +Locked out + +
+

Locked out

+

This account has been locked out, please try again later.

+
diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Login.razor b/src/LudikZoneBlazor/Components/Account/Pages/Login.razor new file mode 100644 index 0000000..1270c70 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Login.razor @@ -0,0 +1,180 @@ +@page "/Account/Login" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject SignInManager SignInManager +@inject ILogger Logger +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Log in + +Log in + + + + + + + + Use a local account to log in. + + + + + + + + + + Remember me + + + Log in + + + + + + + Forgot your password?
+ { ["ReturnUrl"] = ReturnUrl }))">Register as a new user
+ Resend email confirmation +
+
+
+ + Use another service to log in. + + + +
+ +@code { + private string? errorMessage; + + + + [CascadingParameter] + + private HttpContext HttpContext { get; set; } = default!; + + + + [SupplyParameterFromForm] + + private InputModel Input { get; set; } = new(); + + + + [SupplyParameterFromQuery] + + private string? ReturnUrl { get; set; } + + + + protected override async Task OnInitializedAsync() + + { + + if (HttpMethods.IsGet(HttpContext.Request.Method)) + + { + + // Clear the existing external cookie to ensure a clean login process + + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + } + + } + + + + public async Task LoginUser() + + { + + // This doesn't count login failures towards account lockout + + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + + var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + + if (result.Succeeded) + + { + + Logger.LogInformation("User logged in."); + + RedirectManager.RedirectTo(ReturnUrl); + + } + + else if (result.RequiresTwoFactor) + + { + + RedirectManager.RedirectTo( + + "Account/LoginWith2fa", + + new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe }); + + } + + else if (result.IsLockedOut) + + { + + Logger.LogWarning("User account locked out."); + + RedirectManager.RedirectTo("Account/Lockout"); + + } + + else + + { + + errorMessage = "Error: Invalid login attempt."; + + } + + } + + + + private sealed class InputModel + + { + + [Required] + + [EmailAddress] + + public string Email { get; set; } = ""; + + + + [Required] + + [DataType(DataType.Password)] + + public string Password { get; set; } = ""; + + + + [Display(Name = "Remember me?")] + + public bool RememberMe { get; set; } + + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/LoginWith2fa.razor b/src/LudikZoneBlazor/Components/Account/Pages/LoginWith2fa.razor new file mode 100644 index 0000000..2ca0013 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/LoginWith2fa.razor @@ -0,0 +1,101 @@ +@page "/Account/LoginWith2fa" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject SignInManager SignInManager +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Two-factor authentication + +

Two-factor authentication

+ + +

Your login is protected with an authenticator app. Enter your authenticator code below.

+
+
+ + + + + +
+ + + +
+
+ +
+
+ +
+
+
+
+

+ Don't have access to your authenticator device? You can + log in with a recovery code. +

+ +@code { + private string? message; + private ApplicationUser user = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + [SupplyParameterFromQuery] + private bool RememberMe { get; set; } + + protected override async Task OnInitializedAsync() + { + // Ensure the user has gone through the username & password screen first + user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? + throw new InvalidOperationException("Unable to load two-factor authentication user."); + } + + private async Task OnValidSubmitAsync() + { + var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty); + var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine); + var userId = await UserManager.GetUserIdAsync(user); + + if (result.Succeeded) + { + Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId); + RedirectManager.RedirectTo(ReturnUrl); + } + else if (result.IsLockedOut) + { + Logger.LogWarning("User with ID '{UserId}' account locked out.", userId); + RedirectManager.RedirectTo("Account/Lockout"); + } + else + { + Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId); + message = "Error: Invalid authenticator code."; + } + } + + private sealed class InputModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Authenticator code")] + public string? TwoFactorCode { get; set; } + + [Display(Name = "Remember this machine")] + public bool RememberMachine { get; set; } + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/LoginWithRecoveryCode.razor b/src/LudikZoneBlazor/Components/Account/Pages/LoginWithRecoveryCode.razor new file mode 100644 index 0000000..b25cc29 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/LoginWithRecoveryCode.razor @@ -0,0 +1,132 @@ +@page "/Account/LoginWithRecoveryCode" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject SignInManager SignInManager +@inject UserManager UserManager +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Recovery code verification + +

Recovery code verification

+ + +

+ You have requested to log in with a recovery code. This login will not be remembered until you provide + an authenticator app code at log in or disable 2FA and log in again. +

+
+
+ + + +
+ + + +
+ +
+
+
+ +@code { + private string? message; + + private ApplicationUser user = default!; + + + + [SupplyParameterFromForm] + + private InputModel Input { get; set; } = new(); + + + + [SupplyParameterFromQuery] + + private string? ReturnUrl { get; set; } + + + + protected override async Task OnInitializedAsync() + + { + + // Ensure the user has gone through the username & password screen first + + user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? + + throw new InvalidOperationException("Unable to load two-factor authentication user."); + + } + + + + private async Task OnValidSubmitAsync() + + { + + var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty); + + + + var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); + + + + var userId = await UserManager.GetUserIdAsync(user); + + + + if (result.Succeeded) + + { + + Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId); + + RedirectManager.RedirectTo(ReturnUrl); + + } + + else if (result.IsLockedOut) + + { + + Logger.LogWarning("User account locked out."); + + RedirectManager.RedirectTo("Account/Lockout"); + + } + + else + + { + + Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId); + + message = "Error: Invalid recovery code entered."; + + } + + } + + + + private sealed class InputModel + + { + + [Required] + + [DataType(DataType.Text)] + + [Display(Name = "Recovery Code")] + + public string RecoveryCode { get; set; } = ""; + + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/ChangePassword.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/ChangePassword.razor new file mode 100644 index 0000000..294c6d9 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/ChangePassword.razor @@ -0,0 +1,98 @@ +@page "/Account/Manage/ChangePassword" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityUserAccessor UserAccessor +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Change password + +Change password + + + + + + + + + + + + + + + + + + Update password + + + + +@code { + private string? message; + private ApplicationUser user = default!; + private bool hasPassword; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + hasPassword = await UserManager.HasPasswordAsync(user); + if (!hasPassword) + { + RedirectManager.RedirectTo("Account/Manage/SetPassword"); + } + } + + private async Task OnValidSubmitAsync() + { + var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword); + if (!changePasswordResult.Succeeded) + { + message = $"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}"; + return; + } + + await SignInManager.RefreshSignInAsync(user); + Logger.LogInformation("User changed their password successfully."); + + RedirectManager.RedirectToCurrentPageWithStatus("Your password has been changed", HttpContext); + } + + private sealed class InputModel + { + [Required] + [DataType(DataType.Password)] + [Display(Name = "Current password")] + public string OldPassword { get; set; } = ""; + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } = ""; + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } = ""; + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/DeletePersonalData.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/DeletePersonalData.razor new file mode 100644 index 0000000..291177f --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/DeletePersonalData.razor @@ -0,0 +1,86 @@ +@page "/Account/Manage/DeletePersonalData" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityUserAccessor UserAccessor +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Delete Personal Data + +Delete personal data + + + + + Deleting this data will permanently remove your account, and this cannot be recovered. + + + + + + + @if (requirePassword) + { + + + + } + + Delete data and close my account + + + + +@code { + private string? message; + private ApplicationUser user = default!; + private bool requirePassword; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + Input ??= new(); + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + requirePassword = await UserManager.HasPasswordAsync(user); + } + + private async Task OnValidSubmitAsync() + { + if (requirePassword && !await UserManager.CheckPasswordAsync(user, Input.Password)) + { + message = "Error: Incorrect password."; + return; + } + + var result = await UserManager.DeleteAsync(user); + if (!result.Succeeded) + { + throw new InvalidOperationException("Unexpected error occurred deleting user."); + } + + await SignInManager.SignOutAsync(); + + var userId = await UserManager.GetUserIdAsync(user); + Logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId); + + RedirectManager.RedirectToCurrentPage(); + } + + private sealed class InputModel + { + [DataType(DataType.Password)] + public string Password { get; set; } = ""; + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/Disable2fa.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/Disable2fa.razor new file mode 100644 index 0000000..36c91dd --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/Disable2fa.razor @@ -0,0 +1,64 @@ +@page "/Account/Manage/Disable2fa" + +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject IdentityUserAccessor UserAccessor +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Disable two-factor authentication (2FA) + + +

Disable two-factor authentication (2FA)

+ + + +
+
+ + + +
+ +@code { + private ApplicationUser user = default!; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + + if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user)) + { + throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled."); + } + } + + private async Task OnSubmitAsync() + { + var disable2faResult = await UserManager.SetTwoFactorEnabledAsync(user, false); + if (!disable2faResult.Succeeded) + { + throw new InvalidOperationException("Unexpected error occurred disabling 2FA."); + } + + var userId = await UserManager.GetUserIdAsync(user); + Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId); + RedirectManager.RedirectToWithStatus( + "Account/Manage/TwoFactorAuthentication", + "2fa has been disabled. You can reenable 2fa when you setup an authenticator app", + HttpContext); + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/Email.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/Email.razor new file mode 100644 index 0000000..bf1cda8 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/Email.razor @@ -0,0 +1,122 @@ +@page "/Account/Manage/Email" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject IdentityUserAccessor UserAccessor +@inject NavigationManager NavigationManager + +Manage email + +Manage email + + + +
+ + + + + + + + @if (isEmailConfirmed) + { + + + + } + else + { + + + + + Send verification email + + } + + + + + + + Change email + + + + +@code { + private string? message; + private ApplicationUser user = default!; + private string? email; + private bool isEmailConfirmed; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm(FormName = "change-email")] + private InputModel Input { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + email = await UserManager.GetEmailAsync(user); + isEmailConfirmed = await UserManager.IsEmailConfirmedAsync(user); + + Input.NewEmail ??= email; + } + + private async Task OnValidSubmitAsync() + { + if (Input.NewEmail is null || Input.NewEmail == email) + { + message = "Your email is unchanged."; + return; + } + + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmailChange").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["email"] = Input.NewEmail, ["code"] = code }); + + await EmailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl)); + + message = "Confirmation link to change email sent. Please check your email."; + } + + private async Task OnSendEmailVerificationAsync() + { + if (email is null) + { + return; + } + + var userId = await UserManager.GetUserIdAsync(user); + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + new Dictionary { ["userId"] = userId, ["code"] = code }); + + await EmailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(callbackUrl)); + + message = "Verification email sent. Please check your email."; + } + + private sealed class InputModel + { + [Required] + [EmailAddress] + [Display(Name = "New email")] + public string? NewEmail { get; set; } + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/EnableAuthenticator.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/EnableAuthenticator.razor new file mode 100644 index 0000000..f5c6304 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/EnableAuthenticator.razor @@ -0,0 +1,178 @@ +@page "/Account/Manage/EnableAuthenticator" + +@using System.ComponentModel.DataAnnotations +@using System.Globalization +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject IdentityUserAccessor UserAccessor +@inject UrlEncoder UrlEncoder +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Configure authenticator app + +@if (recoveryCodes is not null) +{ + +} +else +{ + Configure authenticator app + + + + To use an authenticator app go through the following steps: + +
    +
  1. + + Download a two-factor authenticator app like Microsoft Authenticator for + Android and + iOS or + Google Authenticator for + Android and + iOS. + +
  2. +
  3. + + Scan the QR Code or enter this key into your two factor authenticator app. Spaces and casing do not matter: + + + @sharedKey + + + Learn how to enable QR code generation. + + +
    +
  4. +
  5. + + Once you have scanned the QR code or input the key above, your two factor authentication app will provide you + with a unique code. Enter the code in the confirmation box below. + + + + + + + + + + Verify + + + +
  6. +
+} + +@code { + private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; + + private string? message; + private ApplicationUser user = default!; + private string? sharedKey; + private string? authenticatorUri; + private IEnumerable? recoveryCodes; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + + await LoadSharedKeyAndQrCodeUriAsync(user); + } + + private async Task OnValidSubmitAsync() + { + // Strip spaces and hyphens + var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty); + + var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync( + user, UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); + + if (!is2faTokenValid) + { + message = "Error: Verification code is invalid."; + return; + } + + await UserManager.SetTwoFactorEnabledAsync(user, true); + var userId = await UserManager.GetUserIdAsync(user); + Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId); + + message = "Your authenticator app has been verified."; + + if (await UserManager.CountRecoveryCodesAsync(user) == 0) + { + recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + } + else + { + RedirectManager.RedirectToWithStatus("Account/Manage/TwoFactorAuthentication", message, HttpContext); + } + } + + private async ValueTask LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user) + { + // Load the authenticator key & QR code URI to display on the form + var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(unformattedKey)) + { + await UserManager.ResetAuthenticatorKeyAsync(user); + unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user); + } + + sharedKey = FormatKey(unformattedKey!); + + var email = await UserManager.GetEmailAsync(user); + authenticatorUri = GenerateQrCodeUri(email!, unformattedKey!); + } + + private string FormatKey(string unformattedKey) + { + var result = new StringBuilder(); + int currentPosition = 0; + while (currentPosition + 4 < unformattedKey.Length) + { + result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' '); + currentPosition += 4; + } + if (currentPosition < unformattedKey.Length) + { + result.Append(unformattedKey.AsSpan(currentPosition)); + } + + return result.ToString().ToLowerInvariant(); + } + + private string GenerateQrCodeUri(string email, string unformattedKey) + { + return string.Format( + CultureInfo.InvariantCulture, + AuthenticatorUriFormat, + UrlEncoder.Encode("Microsoft.AspNetCore.Identity.UI"), + UrlEncoder.Encode(email), + unformattedKey); + } + + private sealed class InputModel + { + [Required] + [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Text)] + [Display(Name = "Verification Code")] + public string Code { get; set; } = ""; + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/ExternalLogins.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/ExternalLogins.razor new file mode 100644 index 0000000..70acfeb --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/ExternalLogins.razor @@ -0,0 +1,140 @@ +@page "/Account/Manage/ExternalLogins" + +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityUserAccessor UserAccessor +@inject IUserStore UserStore +@inject IdentityRedirectManager RedirectManager + +Manage your external logins + + +@if (currentLogins?.Count > 0) +{ +

Registered Logins

+ + + @foreach (var login in currentLogins) + { + + + + + } + +
@login.ProviderDisplayName + @if (showRemoveButton) + { +
+ +
+ + + +
+ + } + else + { + @:   + } +
+} +@if (otherLogins?.Count > 0) +{ +

Add another service to log in.

+ +
+ +
+

+ @foreach (var provider in otherLogins) + { + + } +

+
+ +} + +@code { + public const string LinkLoginCallbackAction = "LinkLoginCallback"; + + private ApplicationUser user = default!; + private IList? currentLogins; + private IList? otherLogins; + private bool showRemoveButton; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private string? LoginProvider { get; set; } + + [SupplyParameterFromForm] + private string? ProviderKey { get; set; } + + [SupplyParameterFromQuery] + private string? Action { get; set; } + + protected override async Task OnInitializedAsync() + { + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + currentLogins = await UserManager.GetLoginsAsync(user); + otherLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()) + .Where(auth => currentLogins.All(ul => auth.Name != ul.LoginProvider)) + .ToList(); + + string? passwordHash = null; + if (UserStore is IUserPasswordStore userPasswordStore) + { + passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted); + } + + showRemoveButton = passwordHash is not null || currentLogins.Count > 1; + + if (HttpMethods.IsGet(HttpContext.Request.Method) && Action == LinkLoginCallbackAction) + { + await OnGetLinkLoginCallbackAsync(); + } + } + + private async Task OnSubmitAsync() + { + var result = await UserManager.RemoveLoginAsync(user, LoginProvider!, ProviderKey!); + if (!result.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not removed.", HttpContext); + } + + await SignInManager.RefreshSignInAsync(user); + RedirectManager.RedirectToCurrentPageWithStatus("The external login was removed.", HttpContext); + } + + private async Task OnGetLinkLoginCallbackAsync() + { + var userId = await UserManager.GetUserIdAsync(user); + var info = await SignInManager.GetExternalLoginInfoAsync(userId); + if (info is null) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: Could not load external login info.", HttpContext); + } + + var result = await UserManager.AddLoginAsync(user, info); + if (!result.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not added. External logins can only be associated with one account.", HttpContext); + } + + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + RedirectManager.RedirectToCurrentPageWithStatus("The external login was added.", HttpContext); + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor new file mode 100644 index 0000000..a906c6a --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor @@ -0,0 +1,68 @@ +@page "/Account/Manage/GenerateRecoveryCodes" + +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject IdentityUserAccessor UserAccessor +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Generate two-factor authentication (2FA) recovery codes + +@if (recoveryCodes is not null) +{ + +} +else +{ +

Generate two-factor authentication (2FA) recovery codes

+ +
+
+ + + +
+} + +@code { + private string? message; + private ApplicationUser user = default!; + private IEnumerable? recoveryCodes; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + + var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user); + if (!isTwoFactorEnabled) + { + throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled."); + } + } + + private async Task OnSubmitAsync() + { + var userId = await UserManager.GetUserIdAsync(user); + recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); + message = "You have generated new recovery codes."; + + Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId); + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/Index.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/Index.razor new file mode 100644 index 0000000..dace840 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/Index.razor @@ -0,0 +1,77 @@ +@page "/Account/Manage" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityUserAccessor UserAccessor +@inject IdentityRedirectManager RedirectManager + +Profile + +Profile + + + + + + + + + + + + + + + Save + + + + +@code { + private ApplicationUser user = default!; + private string? username; + private string? phoneNumber; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + username = await UserManager.GetUserNameAsync(user); + phoneNumber = await UserManager.GetPhoneNumberAsync(user); + + Input.PhoneNumber ??= phoneNumber; + } + + private async Task OnValidSubmitAsync() + { + if (Input.PhoneNumber != phoneNumber) + { + var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber); + if (!setPhoneResult.Succeeded) + { + RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext); + } + } + + await SignInManager.RefreshSignInAsync(user); + RedirectManager.RedirectToCurrentPageWithStatus("Your profile has been updated", HttpContext); + } + + private sealed class InputModel + { + [Phone] + [Display(Name = "Phone number")] + public string? PhoneNumber { get; set; } + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/PersonalData.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/PersonalData.razor new file mode 100644 index 0000000..5f3c009 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/PersonalData.razor @@ -0,0 +1,41 @@ +@page "/Account/Manage/PersonalData" + +@inject IdentityUserAccessor UserAccessor + +Personal Data + +Personal data + + + + + + + Your account contains personal data that you have given us. This page allows you to download or delete that data. + + + + + Deleting this data will permanently remove your account, and this cannot be recovered. + + + +
+ + Download + +
+ + Delete + +
+ +@code { + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + _ = await UserAccessor.GetRequiredUserAsync(HttpContext); + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/ResetAuthenticator.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/ResetAuthenticator.razor new file mode 100644 index 0000000..72f2e3d --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/ResetAuthenticator.razor @@ -0,0 +1,52 @@ +@page "/Account/Manage/ResetAuthenticator" + +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityUserAccessor UserAccessor +@inject IdentityRedirectManager RedirectManager +@inject ILogger Logger + +Reset authenticator key + +Reset authenticator key + + + + + If you reset your authenticator key your authenticator app will not work until you reconfigure it. + + + + This process disables 2FA until you verify your authenticator app. + If you do not complete your authenticator app configuration you may lose access to your account. + + +
+ + + Reset authenticator key + + +@code { + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + private async Task OnSubmitAsync() + { + var user = await UserAccessor.GetRequiredUserAsync(HttpContext); + await UserManager.SetTwoFactorEnabledAsync(user, false); + await UserManager.ResetAuthenticatorKeyAsync(user); + var userId = await UserManager.GetUserIdAsync(user); + Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId); + + await SignInManager.RefreshSignInAsync(user); + + RedirectManager.RedirectToWithStatus( + "Account/Manage/EnableAuthenticator", + "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.", + HttpContext); + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/SetPassword.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/SetPassword.razor new file mode 100644 index 0000000..c1cf3ab --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/SetPassword.razor @@ -0,0 +1,87 @@ +@page "/Account/Manage/SetPassword" + +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityUserAccessor UserAccessor +@inject IdentityRedirectManager RedirectManager + +Set password + +

Set your password

+ +

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

+
+
+ + + +
+ + + +
+
+ + + +
+ +
+
+
+ +@code { + private string? message; + private ApplicationUser user = default!; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + protected override async Task OnInitializedAsync() + { + user = await UserAccessor.GetRequiredUserAsync(HttpContext); + + var hasPassword = await UserManager.HasPasswordAsync(user); + if (hasPassword) + { + RedirectManager.RedirectTo("Account/Manage/ChangePassword"); + } + } + + private async Task OnValidSubmitAsync() + { + var addPasswordResult = await UserManager.AddPasswordAsync(user, Input.NewPassword!); + if (!addPasswordResult.Succeeded) + { + message = $"Error: {string.Join(",", addPasswordResult.Errors.Select(error => error.Description))}"; + return; + } + + await SignInManager.RefreshSignInAsync(user); + RedirectManager.RedirectToCurrentPageWithStatus("Your password has been set.", HttpContext); + } + + private sealed class InputModel + { + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string? NewPassword { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string? ConfirmPassword { get; set; } + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/TwoFactorAuthentication.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/TwoFactorAuthentication.razor new file mode 100644 index 0000000..08808fd --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/TwoFactorAuthentication.razor @@ -0,0 +1,110 @@ +@page "/Account/Manage/TwoFactorAuthentication" + +@using Microsoft.AspNetCore.Http.Features +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject IdentityUserAccessor UserAccessor +@inject IdentityRedirectManager RedirectManager + +Two-factor authentication (2FA) + +Two-factor authentication (2FA) + + + +@if (canTrack) +{ + if (is2faEnabled) + { + if (recoveryCodesLeft == 0) + { + You have no recovery codes left. + + + You must generate a new set of recovery codes + before you can log in with a recovery code. + + } + else if (recoveryCodesLeft == 1) + { + You have 1 recovery code left. + + + You can generate a new set of recovery codes. + + } + else if (recoveryCodesLeft <= 3) + { + You have @recoveryCodesLeft recovery codes left. + + + You should generate a new set of recovery codes. + + } + + if (isMachineRemembered) + { +
+ + + Forget this browser + + } + + Disable 2FA
+ Reset recovery codes + } + + Authenticator app + + @if (!hasAuthenticator) + { + Add authenticator app
+ } + else + { + Set up authenticator app
+ Reset authenticator app + } +} +else +{ + Privacy and cookie policy have not been accepted. + + + You must accept the policy before you can enable two factor authentication. + +} + +@code { + private bool canTrack; + private bool hasAuthenticator; + private int recoveryCodesLeft; + private bool is2faEnabled; + private bool isMachineRemembered; + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + var user = await UserAccessor.GetRequiredUserAsync(HttpContext); + canTrack = HttpContext.Features.Get()?.CanTrack ?? true; + hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null; + is2faEnabled = await UserManager.GetTwoFactorEnabledAsync(user); + isMachineRemembered = await SignInManager.IsTwoFactorClientRememberedAsync(user); + recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user); + } + + private async Task OnSubmitForgetBrowserAsync() + { + await SignInManager.ForgetTwoFactorClientAsync(); + + RedirectManager.RedirectToCurrentPageWithStatus( + "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.", + HttpContext); + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Manage/_Imports.razor b/src/LudikZoneBlazor/Components/Account/Pages/Manage/_Imports.razor new file mode 100644 index 0000000..ada5bb0 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Manage/_Imports.razor @@ -0,0 +1,2 @@ +@layout ManageLayout +@attribute [Microsoft.AspNetCore.Authorization.Authorize] diff --git a/src/LudikZoneBlazor/Components/Account/Pages/Register.razor b/src/LudikZoneBlazor/Components/Account/Pages/Register.razor new file mode 100644 index 0000000..a0c82eb --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/Register.razor @@ -0,0 +1,232 @@ +@page "/Account/Register" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject IUserStore UserStore +@inject SignInManager SignInManager +@inject IEmailSender EmailSender +@inject ILogger Logger +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Register + +Register + + + + + + + + Create a new account. + + + + + + + + + + + + + Register + + + + + + Use another service to register. + + + + +@code { + private IEnumerable? identityErrors; + + + + [SupplyParameterFromForm] + + private InputModel Input { get; set; } = new(); + + + + [SupplyParameterFromQuery] + + private string? ReturnUrl { get; set; } + + + + private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}"; + + + + public async Task RegisterUser(EditContext editContext) + + { + + var user = CreateUser(); + + + + await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None); + + var emailStore = GetEmailStore(); + + await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None); + + var result = await UserManager.CreateAsync(user, Input.Password); + + + + if (!result.Succeeded) + + { + + identityErrors = result.Errors; + + return; + + } + + + + Logger.LogInformation("User created a new account with password."); + + + + var userId = await UserManager.GetUserIdAsync(user); + + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + + new Dictionary { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl }); + + + + await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); + + + + if (UserManager.Options.SignIn.RequireConfirmedAccount) + + { + + RedirectManager.RedirectTo( + + "Account/RegisterConfirmation", + + new() { ["email"] = Input.Email, ["returnUrl"] = ReturnUrl }); + + } + + + + await SignInManager.SignInAsync(user, isPersistent: false); + + RedirectManager.RedirectTo(ReturnUrl); + + } + + + + private ApplicationUser CreateUser() + + { + + try + + { + + return Activator.CreateInstance(); + + } + + catch + + { + + throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " + + + $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor."); + + } + + } + + + + private IUserEmailStore GetEmailStore() + + { + + if (!UserManager.SupportsUserEmail) + + { + + throw new NotSupportedException("The default UI requires a user store with email support."); + + } + + return (IUserEmailStore)UserStore; + + } + + + + private sealed class InputModel + + { + + [Required] + + [EmailAddress] + + [Display(Name = "Email")] + + public string Email { get; set; } = ""; + + + + [Required] + + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + + [DataType(DataType.Password)] + + [Display(Name = "Password")] + + public string Password { get; set; } = ""; + + + + [DataType(DataType.Password)] + + [Display(Name = "Confirm password")] + + [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] + + public string ConfirmPassword { get; set; } = ""; + + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/RegisterConfirmation.razor b/src/LudikZoneBlazor/Components/Account/Pages/RegisterConfirmation.razor new file mode 100644 index 0000000..71f2103 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/RegisterConfirmation.razor @@ -0,0 +1,106 @@ +@page "/Account/RegisterConfirmation" + +@using System.Text +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Register confirmation + +

Register confirmation

+ + + +@if (emailConfirmationLink is not null) + +{ +

+ This app does not currently have a real email sender registered, see these docs for how to configure a real email sender. + Normally this would be emailed: Click here to confirm your account +

+} + +else + +{ +

Please check your email to confirm your account.

+} + +@code { + private string? emailConfirmationLink; + + private string? statusMessage; + + + + [CascadingParameter] + + private HttpContext HttpContext { get; set; } = default!; + + + + [SupplyParameterFromQuery] + + private string? Email { get; set; } + + + + [SupplyParameterFromQuery] + + private string? ReturnUrl { get; set; } + + + + protected override async Task OnInitializedAsync() + + { + + if (Email is null) + + { + + RedirectManager.RedirectTo(""); + + } + + + + var user = await UserManager.FindByEmailAsync(Email); + + if (user is null) + + { + + HttpContext.Response.StatusCode = StatusCodes.Status404NotFound; + + statusMessage = "Error finding user for unspecified email"; + + } + + else if (EmailSender is IdentityNoOpEmailSender) + + { + + // Once you add a real email sender, you should remove this code that lets you confirm the account + + var userId = await UserManager.GetUserIdAsync(user); + + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + emailConfirmationLink = NavigationManager.GetUriWithQueryParameters( + + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + + new Dictionary { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl }); + + } + + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/ResendEmailConfirmation.razor b/src/LudikZoneBlazor/Components/Account/Pages/ResendEmailConfirmation.razor new file mode 100644 index 0000000..b602d9c --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/ResendEmailConfirmation.razor @@ -0,0 +1,100 @@ +@page "/Account/ResendEmailConfirmation" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using LudikZoneBlazor.Data + +@inject UserManager UserManager +@inject IEmailSender EmailSender +@inject NavigationManager NavigationManager +@inject IdentityRedirectManager RedirectManager + +Resend email confirmation + +Resend email confirmation + +Enter your email. + + + + + + + + + + + + Resend + + + + +@code { + private string? message; + + + + [SupplyParameterFromForm] + + private InputModel Input { get; set; } = new(); + + + + private async Task OnValidSubmitAsync() + + { + + var user = await UserManager.FindByEmailAsync(Input.Email!); + + if (user is null) + + { + + message = "Verification email sent. Please check your email."; + + return; + + } + + + + var userId = await UserManager.GetUserIdAsync(user); + + var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); + + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var callbackUrl = NavigationManager.GetUriWithQueryParameters( + + NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri, + + new Dictionary { ["userId"] = userId, ["code"] = code }); + + await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl)); + + + + message = "Verification email sent. Please check your email."; + + } + + + + private sealed class InputModel + + { + + [Required] + + [EmailAddress] + + public string Email { get; set; } = ""; + + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/ResetPassword.razor b/src/LudikZoneBlazor/Components/Account/Pages/ResetPassword.razor new file mode 100644 index 0000000..a9e87d7 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/ResetPassword.razor @@ -0,0 +1,159 @@ +@page "/Account/ResetPassword" + +@using System.ComponentModel.DataAnnotations +@using System.Text +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.WebUtilities +@using LudikZoneBlazor.Data + +@inject IdentityRedirectManager RedirectManager +@inject UserManager UserManager + +Reset password + +

Reset password

+

Reset your password.

+ +
+
+ + + + + + +
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+
+ +@code { + private IEnumerable? identityErrors; + + + + [SupplyParameterFromForm] + + private InputModel Input { get; set; } = new(); + + + + [SupplyParameterFromQuery] + + private string? Code { get; set; } + + + + private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}"; + + + + protected override void OnInitialized() + + { + + if (Code is null) + + { + + RedirectManager.RedirectTo("Account/InvalidPasswordReset"); + + } + + + + Input.Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); + + } + + + + private async Task OnValidSubmitAsync() + + { + + var user = await UserManager.FindByEmailAsync(Input.Email); + + if (user is null) + + { + + // Don't reveal that the user does not exist + + RedirectManager.RedirectTo("Account/ResetPasswordConfirmation"); + + } + + + + var result = await UserManager.ResetPasswordAsync(user, Input.Code, Input.Password); + + if (result.Succeeded) + + { + + RedirectManager.RedirectTo("Account/ResetPasswordConfirmation"); + + } + + + + identityErrors = result.Errors; + + } + + + + private sealed class InputModel + + { + + [Required] + + [EmailAddress] + + public string Email { get; set; } = ""; + + + + [Required] + + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + + [DataType(DataType.Password)] + + public string Password { get; set; } = ""; + + + + [DataType(DataType.Password)] + + [Display(Name = "Confirm password")] + + [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] + + public string ConfirmPassword { get; set; } = ""; + + + + [Required] + + public string Code { get; set; } = ""; + + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Pages/ResetPasswordConfirmation.razor b/src/LudikZoneBlazor/Components/Account/Pages/ResetPasswordConfirmation.razor new file mode 100644 index 0000000..ac54ab6 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/ResetPasswordConfirmation.razor @@ -0,0 +1,7 @@ +@page "/Account/ResetPasswordConfirmation" +Reset password confirmation + +

Reset password confirmation

+

+ Your password has been reset. Please click here to log in. +

diff --git a/src/LudikZoneBlazor/Components/Account/Pages/_Imports.razor b/src/LudikZoneBlazor/Components/Account/Pages/_Imports.razor new file mode 100644 index 0000000..0501034 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Pages/_Imports.razor @@ -0,0 +1,3 @@ +@using MudBlazor +@using LudikZoneBlazor.Components.Account.Shared +@layout AccountLayout diff --git a/src/LudikZoneBlazor/Components/Account/Shared/AccountLayout.razor b/src/LudikZoneBlazor/Components/Account/Shared/AccountLayout.razor new file mode 100644 index 0000000..d1a5c90 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Shared/AccountLayout.razor @@ -0,0 +1,43 @@ +@inherits LayoutComponentBase +@layout LudikZoneBlazor.Components.Layout.MainLayout +@inject NavigationManager NavigationManager + +@if (HttpContext is null) + +{ +

Loading...

+} + +else + +{ + @Body +} + +@code { + [CascadingParameter] + + private HttpContext? HttpContext { get; set; } + + + + protected override void OnParametersSet() + + { + + if (HttpContext is null) + + { + + // If this code runs, we're currently rendering in interactive mode, so there is no HttpContext. + + // The identity pages need to set cookies, so they require an HttpContext. To achieve this we + + // must transition back from interactive mode to a server-rendered page. + + NavigationManager.Refresh(forceReload: true); + + } + + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Shared/ExternalLoginPicker.razor b/src/LudikZoneBlazor/Components/Account/Shared/ExternalLoginPicker.razor new file mode 100644 index 0000000..f0f4eab --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Shared/ExternalLoginPicker.razor @@ -0,0 +1,44 @@ +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Identity +@using MudBlazor +@using LudikZoneBlazor.Data +@using MudBlazor.StaticInput + +@inject SignInManager SignInManager +@inject IdentityRedirectManager RedirectManager + +@if (externalLogins.Length == 0) +{ + There are no external authentication services configured. + + See this article + about setting up this ASP.NET application to support logging in via external services + +} +else +{ +
+
+ + +

+ @foreach (var provider in externalLogins) + { + + } +

+
+
+} + +@code { + private AuthenticationScheme[] externalLogins = []; + + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + protected override async Task OnInitializedAsync() + { + externalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToArray(); + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Shared/ManageLayout.razor b/src/LudikZoneBlazor/Components/Account/Shared/ManageLayout.razor new file mode 100644 index 0000000..6ab82b4 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Shared/ManageLayout.razor @@ -0,0 +1,14 @@ +@inherits LayoutComponentBase +@layout AccountLayout + +Manage your account + + + + Change your account settings + + + + @Body + + diff --git a/src/LudikZoneBlazor/Components/Account/Shared/ManageNavMenu.razor b/src/LudikZoneBlazor/Components/Account/Shared/ManageNavMenu.razor new file mode 100644 index 0000000..e840dab --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Shared/ManageNavMenu.razor @@ -0,0 +1,25 @@ +@using Microsoft.AspNetCore.Identity +@using LudikZoneBlazor.Data + +@inject SignInManager SignInManager + + + Profile + Email + Password + @if (hasExternalLogins) + { + External logins + } + Two-factor authentication + Personal data + + +@code { + private bool hasExternalLogins; + + protected override async Task OnInitializedAsync() + { + hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Shared/RedirectToLogin.razor b/src/LudikZoneBlazor/Components/Account/Shared/RedirectToLogin.razor new file mode 100644 index 0000000..c8b8eff --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Shared/RedirectToLogin.razor @@ -0,0 +1,8 @@ +@inject NavigationManager NavigationManager + +@code { + protected override void OnInitialized() + { + NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true); + } +} diff --git a/src/LudikZoneBlazor/Components/Account/Shared/ShowRecoveryCodes.razor b/src/LudikZoneBlazor/Components/Account/Shared/ShowRecoveryCodes.razor new file mode 100644 index 0000000..aa92e11 --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Shared/ShowRecoveryCodes.razor @@ -0,0 +1,28 @@ + +

Recovery codes

+ +
+
+ @foreach (var recoveryCode in RecoveryCodes) + { +
+ @recoveryCode +
+ } +
+
+ +@code { + [Parameter] + public string[] RecoveryCodes { get; set; } = []; + + [Parameter] + public string? StatusMessage { get; set; } +} diff --git a/src/LudikZoneBlazor/Components/Account/Shared/StatusMessage.razor b/src/LudikZoneBlazor/Components/Account/Shared/StatusMessage.razor new file mode 100644 index 0000000..4c2821d --- /dev/null +++ b/src/LudikZoneBlazor/Components/Account/Shared/StatusMessage.razor @@ -0,0 +1,28 @@ +@if (!string.IsNullOrEmpty(DisplayMessage)) +{ + var severity = DisplayMessage.StartsWith("Error") ? Severity.Error : Severity.Success; + + @DisplayMessage +} + +@code { + private string? messageFromCookie; + + [Parameter] + public string? Message { get; set; } + + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + private string? DisplayMessage => Message ?? messageFromCookie; + + protected override void OnInitialized() + { + messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName]; + + if (messageFromCookie is not null) + { + HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName); + } + } +} diff --git a/src/LudikZoneBlazor/Components/App.razor b/src/LudikZoneBlazor/Components/App.razor index fc42740..e435714 100644 --- a/src/LudikZoneBlazor/Components/App.razor +++ b/src/LudikZoneBlazor/Components/App.razor @@ -8,13 +8,23 @@ - + - + + + +@code { + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + private IComponentRenderMode? RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/Account") + ? null + : InteractiveServer; +} diff --git a/src/LudikZoneBlazor/Components/Layout/MainLayout.razor b/src/LudikZoneBlazor/Components/Layout/MainLayout.razor index ae6f235..9593ea4 100644 --- a/src/LudikZoneBlazor/Components/Layout/MainLayout.razor +++ b/src/LudikZoneBlazor/Components/Layout/MainLayout.razor @@ -1,6 +1,6 @@ @inherits LayoutComponentBase - + @@ -9,7 +9,6 @@ Application - @@ -29,76 +28,12 @@ @code { private bool _drawerOpen = true; - private bool _isDarkMode = true; - private MudTheme? _theme = null; - - protected override void OnInitialized() - { - base.OnInitialized(); - - _theme = new() - { - PaletteLight = _lightPalette, - PaletteDark = _darkPalette, - LayoutProperties = new LayoutProperties() - }; - } - private void DrawerToggle() { _drawerOpen = !_drawerOpen; } - private void DarkModeToggle() - { - _isDarkMode = !_isDarkMode; - } - - private readonly PaletteLight _lightPalette = new() - { - Black = "#110e2d", - AppbarText = "#424242", - AppbarBackground = "rgba(255,255,255,0.8)", - DrawerBackground = "#ffffff", - GrayLight = "#e8e8e8", - GrayLighter = "#f9f9f9", - }; - - private readonly PaletteDark _darkPalette = new() - { - Primary = "#7e6fff", - Surface = "#1e1e2d", - Background = "#1a1a27", - BackgroundGray = "#151521", - AppbarText = "#92929f", - AppbarBackground = "rgba(26,26,39,0.8)", - DrawerBackground = "#1a1a27", - ActionDefault = "#74718e", - ActionDisabled = "#9999994d", - ActionDisabledBackground = "#605f6d4d", - TextPrimary = "#b2b0bf", - TextSecondary = "#92929f", - TextDisabled = "#ffffff33", - DrawerIcon = "#92929f", - DrawerText = "#92929f", - GrayLight = "#2a2833", - GrayLighter = "#1e1e2d", - Info = "#4a86ff", - Success = "#3dcb6c", - Warning = "#ffb545", - Error = "#ff3f5f", - LinesDefault = "#33323e", - TableLines = "#33323e", - Divider = "#292838", - OverlayLight = "#1e1e2d80", - }; - - public string DarkLightModeButtonIcon => _isDarkMode switch - { - true => Icons.Material.Rounded.AutoMode, - false => Icons.Material.Outlined.DarkMode, - }; } diff --git a/src/LudikZoneBlazor/Components/Layout/NavMenu.razor b/src/LudikZoneBlazor/Components/Layout/NavMenu.razor index 0225ad0..094949a 100644 --- a/src/LudikZoneBlazor/Components/Layout/NavMenu.razor +++ b/src/LudikZoneBlazor/Components/Layout/NavMenu.razor @@ -1,8 +1,50 @@ - +@implements IDisposable + +@inject NavigationManager NavigationManager + Home Counter Weather + Auth Required + + + @context.User.Identity?.Name +
+ + + + +
+ + Register + Login + +
+@code { + private string? currentUrl; + + protected override void OnInitialized() + { + currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + NavigationManager.LocationChanged += OnLocationChanged; + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + currentUrl = NavigationManager.ToBaseRelativePath(e.Location); + StateHasChanged(); + } + + public void Dispose() + { + NavigationManager.LocationChanged -= OnLocationChanged; + } +} + + diff --git a/src/LudikZoneBlazor/Components/Pages/Auth.razor b/src/LudikZoneBlazor/Components/Pages/Auth.razor new file mode 100644 index 0000000..66b626e --- /dev/null +++ b/src/LudikZoneBlazor/Components/Pages/Auth.razor @@ -0,0 +1,14 @@ +@page "/auth" + +@using Microsoft.AspNetCore.Authorization + +@attribute [Authorize] + +Auth + + +You are authenticated! + + + Hello @context.User.Identity?.Name! + diff --git a/src/LudikZoneBlazor/Components/Routes.razor b/src/LudikZoneBlazor/Components/Routes.razor index f756e19..f5983bd 100644 --- a/src/LudikZoneBlazor/Components/Routes.razor +++ b/src/LudikZoneBlazor/Components/Routes.razor @@ -1,6 +1,11 @@ - +@using LudikZoneBlazor.Components.Account.Shared + - + + + + + diff --git a/src/LudikZoneBlazor/Components/_Imports.razor b/src/LudikZoneBlazor/Components/_Imports.razor index 252c514..0f22753 100644 --- a/src/LudikZoneBlazor/Components/_Imports.razor +++ b/src/LudikZoneBlazor/Components/_Imports.razor @@ -1,5 +1,7 @@ @using System.Net.Http @using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization +@using MudBlazor.StaticInput @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web diff --git a/src/LudikZoneBlazor/Data/ApplicationDbContext.cs b/src/LudikZoneBlazor/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..6ad52a7 --- /dev/null +++ b/src/LudikZoneBlazor/Data/ApplicationDbContext.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace LudikZoneBlazor.Data; + +public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) +{ +} diff --git a/src/LudikZoneBlazor/Data/ApplicationUser.cs b/src/LudikZoneBlazor/Data/ApplicationUser.cs new file mode 100644 index 0000000..24f0d2f --- /dev/null +++ b/src/LudikZoneBlazor/Data/ApplicationUser.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Identity; + +namespace LudikZoneBlazor.Data; + +// Add profile data for application users by adding properties to the ApplicationUser class +public class ApplicationUser : IdentityUser +{ +} + diff --git a/src/LudikZoneBlazor/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs b/src/LudikZoneBlazor/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs new file mode 100644 index 0000000..2ab8991 --- /dev/null +++ b/src/LudikZoneBlazor/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs @@ -0,0 +1,268 @@ +// +using System; +using LudikZoneBlazor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LudikZoneBlazor.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("00000000000000_CreateIdentitySchema")] + partial class CreateIdentitySchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + + modelBuilder.Entity("LudikZoneBlazor.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("LudikZoneBlazor.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("LudikZoneBlazor.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LudikZoneBlazor.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("LudikZoneBlazor.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/LudikZoneBlazor/Data/Migrations/00000000000000_CreateIdentitySchema.cs b/src/LudikZoneBlazor/Data/Migrations/00000000000000_CreateIdentitySchema.cs new file mode 100644 index 0000000..ffe8b34 --- /dev/null +++ b/src/LudikZoneBlazor/Data/Migrations/00000000000000_CreateIdentitySchema.cs @@ -0,0 +1,222 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LudikZoneBlazor.Migrations +{ + /// + public partial class CreateIdentitySchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "INTEGER", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoleId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "TEXT", nullable: false), + ProviderKey = table.Column(type: "TEXT", nullable: false), + ProviderDisplayName = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/src/LudikZoneBlazor/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/LudikZoneBlazor/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..6c1013a --- /dev/null +++ b/src/LudikZoneBlazor/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,265 @@ +// +using System; +using LudikZoneBlazor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LudikZoneBlazor.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + + modelBuilder.Entity("LudikZoneBlazor.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("LudikZoneBlazor.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("LudikZoneBlazor.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LudikZoneBlazor.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("LudikZoneBlazor.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/LudikZoneBlazor/Data/app.db b/src/LudikZoneBlazor/Data/app.db new file mode 100644 index 0000000000000000000000000000000000000000..e799a27444236ec4bde77e012adfc02fee0ab47c GIT binary patch literal 102400 zcmeI5&u`mC7RN<9mMkZ>lBS8a=x)o>i){o)j(^+1B1jmSjulIkB2kW=B2a0Gw$(_Y zBT7jebPul60`0NbTd~0Y1AFXg(c>O^-CnxK0(;q>_Yk0$o#7Ajhsnr6;)PRv2{|F> z$D22w_vX!z9LvQ!nOa+6H`P`{ZnM*&v!TAe&<&Oig+hJg=N8dyRoT-4>a&Sej z8irJ=iy@dx?7ue>i6@iH=W1Kts4GTGB~58b>I0>zd9Pz;%MyPxBUl}@ixbJiLo7Ks znwG9|tR$>Ste7J|rA+3snVC_yYR!C0eN?L|t$;E*BLC{Ea=oKi)oih;oLH0!d|H%P zF27>3bn2Ebq_ccsom~;u2?0~X%Pi+sha?#0F|~DyT{u_&h8h^MB-y(%oiVdEUf#oU0lB$4zjPFdqne^zU6B?Cry`;%m#vNWXeo* zGm0+8F^;Su#IbIhSn_;qVrmj(lb6we`opn!#kJvZ{LWZ+uP|^poI+0+*`FBFNBtM) zJwBli1;?m2u1^Pfg^_K$g?l9VIsj*l`xRx^8pEFOV3k;`X*+d!*BJ@y(Qb`j-$nwx z%u?9xW^T1f0I)QmI(BtAL#AUW9*f1VpFUa~Ftu@*+(UU zWEj=H9&|}{tWS*M_CP+mQElNI+tsMFaNw#y*Wq;;)mH0RCj&A;KdTQ!;-jO?m+yPV zl_QL5VL$Nt?BHhZ^7i0SM3xK=*3Kal5G?JbCv7yKkkF88bt@)2BCTCHsDz%LQk$E# zRzvZL2bP9;S<@b?t?II@ZTmD-R4ScTt-V`p%Z(kMe9{6b&?3Vnut-Y=KI8CGI!H_})lX0Pd8wZB(q3xT^6}CD=d+zG zpuh`^PqCcpjz!nW!lGXc?5#18`20NcSx(1NE(=T9+Ez<$*VLxAT+@j0cYPE4+^BrZ z*w7AS9twWmF&-euha*{|R6CXSDp_F?Wv$U-E~N;ObYD1re?G`jeA_8c`X=`qchql3 zhsg;O`$vd;aDV^^fB*=900@8p2!H?xfB*=900^990#}*-;`D^;D5qMA+$K+Em1dhh zeyeON4S8XFVtgXms*F#KPfgFvO^r{^43T~@v44fg2L}j%00@8p2!H?xfB*=900@8p z2!OzwM&J+q#i;9vz=ZE30s8)*Z2HFm0w4eaAOHd&00JNY0w4eaAOHd&@GB-j-~Z$O z|5t2abPWVR00ck)1V8`;KmY_l00ck)1PB5B_y0%!9U>nbAOHd&00JNY0w4eaAOHd& z00JQJMicn#9p+qVnjF!+*=#8^I2ei!PHxOA6ASWea;hpXBxfe)s>zMb%3QKCd39#C zvbiyTbz$L=^0f0vZr<2d+w^NR$17@MRk&AL<;5GzIVsDhGvldT_GqQ}J3KkkJijh) z(K8`4ckjxR+hd*V18KceFQ}P2xqP;=Rjia&#F^W9errOg3Qw=huTSqjnOvXRT3A`Q zedTFqdts;Z$-__PmLFfcHoLlZGbg1|vn!dK!rgr8_S(|jn*~W+SzgJn&Cf2*Y|Kr` z)#++do|&Ca&d8fnNqKH!E?KQCY)(`rrz`UOl=1ujKZasIzR@Oz@*n^LAOHd&00JNY z0w4eaAOHd&(31o{>@P)+@{9w&|KF4Dflh+}2!H?xfB*=900@8p2!H?x^cVr$|M!@| z(OnP#0T2KI5C8!X009sH0T2Lzo+N<#|DH5EIt>CK00JNY0w4eaAOHd&00JP;V+3&j z-(vIIKmY_l00ck)1V8`;KmY`Kk^uhxUr(AHody9A009sH0T2KI5C8!X009u_ zF#>r1-(vIIKmY_l00ck)1V8`;KmY`Kk^tWS_oUg;X%GMb5C8!X009sH0T2KI z5CDN5BS7E(GqFF1$Oi`qfB*=900@8p2!H?xfB*=900@A<|B=8a%s}Z%GCR33uS_h+ zv&pHdypWukoU0}`HY;<<%H-9V*~;d|{MChpk9ciIRN5s?X%*XQOS#W?+S^LAU8~6C zaz)wYODpO8?UW?mo?5zlYihM5-kr|flhzg&i;Kc+afQDBXJX%m$Oi`qfB*=900@8p z2!H?xfB*=900@A zzqAIi=aG-&e3IGwpw_G^Ptt2;`|Ao)b-C8i%JiEH(p7Kr`BXvRC4o(gi^3Yq1y|(Q zoXB$C>KuECGX!&q`ngE_!%=4MU0sEOT31B5p`@ukMm3tGy=69764Q4|2kp8_JDbw$ z&{jmX)sXA8r%IJd+PNVinSGfdr&qX04 z5MNI5#T36N3=bu)$HMVqk_qV^sXeUM+DcjOv{n7u(YVpc@}$>dXJgM#4aUdDm@hu^ zSeUwi!@&CGaFBiV+#{OT^etcGIcd6#WHu0FBU5Ign^ANzCKlt|tm(0dCC|qudbMDi zyo?4kt{jV3TpJF@?~HZ#3Il<|DfEPq*mLH;bku)w-s2PcP;fYfaeX?-D~xR0E!-o? z*8w`H{vBt0`JXj?bYuZj--gQO-d$hkW!UTGmrLf!0+-j2mU}->g?CNrcOvg|> z7K>j$eY7}WYU41uk#c6AOx=2Xd zW4We0>?qBO;<=2v@Ov^ZHOcznGO3x^4@C|em5yp_=Pb17tJ+XMONMs?Qi zYE)X7iuPRq>eb2aZgi$T*2VS2Y+{|5If=3ZqGB{W} zhfF}Qw3nW=(SSliL$1{=<2WL%T{)bDvaD_UG*nb7 zomQ>ATWrgX9iM#C0x8fU!zHjtep_uSVyCfTFDxBBapWFBTj{a7Bv;5+qX|vAHhw|B zGHT_4+Gz{T1K)NudXU*g2wx#lKwBa}=6dd^FGsaea%O9PzGcCvs1FEBpEa;iHPT_+2Relf7O#zf-t z^UP;C9ZR_^EM;q3ExBD&o7!?sBgWtLP3&`{@-1USJCJ!O_<6^8fFK`^WQ|hoRNAX# zg-MjPMvJ+WB1F=C;rRXeAV=|Sr#$JK+)wW12kHGh-v7V!Qw>xA0T2KI5C8!X009sH z0T2KI5CDNUjsX4pf5YMLLnGOdGiT;cKOed~bdkLO|NY=!2Y(y+OXQCv3kL{*00@8p z2%KC3I~R?8UZeUh8tavqW1pA)YVIBL8NYj=ro!)|L7o73pgZ^vnC8LVzsd0Cs zfz`Y^=#^<_4XJc9+j-y3Y_uhFPXax4DqtjEc!374ie43rOsaz+nCkFWpbq96eyJ&3Qk+ibsZCW0M^-d&+q z;Cw@OAiny3w22{o)MF2#z8zVg&8&Tg+{xU7i1+^|cS#G)KmY_l00ck)1V8`;KmY_l z00cnbbrGQV|4i&ZA@aci0w4eaAOHd&00JNY0w4eaAOHd&@TL>E*gqXz74DT*dGW?_ tPRjD>%y=r7#r^-AZdm9K0w4eaAOHd&00JNY0w4eaAOHdm0s;5`{{zod8%F>D literal 0 HcmV?d00001 diff --git a/src/LudikZoneBlazor/LudikZoneBlazor.csproj b/src/LudikZoneBlazor/LudikZoneBlazor.csproj index e4aa989..08f9275 100644 --- a/src/LudikZoneBlazor/LudikZoneBlazor.csproj +++ b/src/LudikZoneBlazor/LudikZoneBlazor.csproj @@ -4,10 +4,21 @@ net8.0 enable enable + aspnet-LudikZoneBlazor-b18f2a2e-d082-4232-8521-6cacaa2a4ba2 + + + + + + + + + + diff --git a/src/LudikZoneBlazor/Program.cs b/src/LudikZoneBlazor/Program.cs index 08009fe..328156d 100644 --- a/src/LudikZoneBlazor/Program.cs +++ b/src/LudikZoneBlazor/Program.cs @@ -1,5 +1,10 @@ +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using LudikZoneBlazor.Components; +using LudikZoneBlazor.Components.Account; +using LudikZoneBlazor.Data; var builder = WebApplication.CreateBuilder(args); @@ -10,10 +15,38 @@ builder.Services.AddMudServices(); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddIdentityCookies(); + +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); +builder.Services.AddDbContext(options => + options.UseSqlite(connectionString)); +builder.Services.AddDatabaseDeveloperPageExceptionFilter(); + +builder.Services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true) + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + +builder.Services.AddSingleton, IdentityNoOpEmailSender>(); + var app = builder.Build(); // Configure the HTTP request pipeline. -if (!app.Environment.IsDevelopment()) +if (app.Environment.IsDevelopment()) +{ + app.UseMigrationsEndPoint(); +} +else { app.UseExceptionHandler("/Error", createScopeForErrors: true); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. @@ -28,4 +61,7 @@ app.UseAntiforgery(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); +// Add additional endpoints required by the Identity /Account Razor components. +app.MapAdditionalIdentityEndpoints(); + app.Run(); diff --git a/src/LudikZoneBlazor/Properties/launchSettings.json b/src/LudikZoneBlazor/Properties/launchSettings.json index de483f5..4574cfc 100644 --- a/src/LudikZoneBlazor/Properties/launchSettings.json +++ b/src/LudikZoneBlazor/Properties/launchSettings.json @@ -4,8 +4,8 @@ "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { - "applicationUrl": "http://localhost:15132", - "sslPort": 44355 + "applicationUrl": "http://localhost:34997", + "sslPort": 44310 } }, "profiles": { @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "http://localhost:5031", + "applicationUrl": "http://localhost:5147", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -22,7 +22,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://localhost:7201;http://localhost:5031", + "applicationUrl": "https://localhost:7076;http://localhost:5147", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/LudikZoneBlazor/appsettings.json b/src/LudikZoneBlazor/appsettings.json index 10f68b8..9daf2e9 100644 --- a/src/LudikZoneBlazor/appsettings.json +++ b/src/LudikZoneBlazor/appsettings.json @@ -1,9 +1,12 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} + "ConnectionStrings": { + "DefaultConnection": "DataSource=Data\\app.db;Cache=Shared" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file