From 78733295e03163f5bcc74e4931b796138576fdd0 Mon Sep 17 00:00:00 2001 From: Lucas Morgan Date: Fri, 16 Jan 2026 17:14:03 -0600 Subject: [PATCH] yep --- .../BackEnd/Controllers/AccountController.cs | 26 +- .../BackEnd/Identity/ServerAccountManager.cs | 72 +++++ .../CookieAuthenticationStateProvider.cs | 276 ------------------ Shogi/FrontEnd/Components/App.razor | 9 +- .../FrontEnd/Components/Layout/NavMenu.razor | 4 +- .../Components/Pages/Identity/LoginPage.razor | 117 +------- Shogi/Program.cs | 5 +- Shogi/Properties/launchSettings.json | 49 ++-- 8 files changed, 129 insertions(+), 429 deletions(-) create mode 100644 Shogi/BackEnd/Identity/ServerAccountManager.cs delete mode 100644 Shogi/FrontEnd/Client/CookieAuthenticationStateProvider.cs diff --git a/Shogi/BackEnd/Controllers/AccountController.cs b/Shogi/BackEnd/Controllers/AccountController.cs index 0c8f402..1de4cf5 100644 --- a/Shogi/BackEnd/Controllers/AccountController.cs +++ b/Shogi/BackEnd/Controllers/AccountController.cs @@ -60,21 +60,29 @@ public class AccountController( } } - return this.Ok(); + return this.NoContent(); } - [HttpPost("/backend/logout")] - public async Task Logout([FromBody] object empty) + [HttpPost("Login")] + [AllowAnonymous] + public async Task Login([FromForm] string email, [FromForm] string password) { - // https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support - if (empty is not null) + var result = await signInManager.PasswordSignInAsync(email, password, isPersistent: true, lockoutOnFailure: false); + if (result.Succeeded) { - await signInManager.SignOutAsync(); - - return this.Ok(); + return Redirect("/"); } - return this.Unauthorized(); + return Redirect("/login?error=Invalid login attempt."); + } + + [HttpGet("Logout")] + [HttpPost("Logout")] + [AllowAnonymous] + public async Task Logout() + { + await signInManager.SignOutAsync(); + return Redirect("/"); } [HttpGet("/backend/roles")] diff --git a/Shogi/BackEnd/Identity/ServerAccountManager.cs b/Shogi/BackEnd/Identity/ServerAccountManager.cs new file mode 100644 index 0000000..b543fda --- /dev/null +++ b/Shogi/BackEnd/Identity/ServerAccountManager.cs @@ -0,0 +1,72 @@ +namespace Shogi.BackEnd.Identity; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.WebUtilities; +using Shogi.FrontEnd.Client; +using System.Text; +using System.Text.Encodings.Web; + +public class ServerAccountManager( + UserManager userManager, + IHttpContextAccessor httpContextAccessor, + IEmailSender emailSender) : IAccountManagement +{ + public async Task RegisterAsync(string email, string password) + { + var user = new ShogiUser { UserName = email, Email = email }; + var result = await userManager.CreateAsync(user, password); + if (result.Succeeded) + { + var userId = await userManager.GetUserIdAsync(user); + var code = await userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var request = httpContextAccessor.HttpContext?.Request; + var host = request?.Host.Value ?? "localhost"; + var scheme = request?.Scheme ?? "https"; + var callbackUrl = $"{scheme}://{host}/backend/confirmEmail?userId={userId}&code={code}"; + + await emailSender.SendEmailAsync(email, "Confirm your email", + $"Please confirm your account by clicking here."); + + return new FormResult { Succeeded = true }; + } + return new FormResult { Succeeded = false, ErrorList = result.Errors.Select(e => e.Description).ToArray() }; + } + + public Task LoginAsync(string email, string password) + { + throw new NotSupportedException("Login must be performed via Form POST to /backend/Account/Login in Server-Side Rendering."); + } + + public Task LogoutAsync() + { + // Logout should be performed via Form POST or Link to /backend/Account/Logout. + return Task.CompletedTask; + } + + public Task CheckAuthenticatedAsync() + { + return Task.FromResult(httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false); + } + + public async Task RequestPasswordReset(string email) + { + var user = await userManager.FindByEmailAsync(email); + if (user != null) + { + // Generate token and send email logic would go here. + } + return new HttpResponseMessage(System.Net.HttpStatusCode.OK); + } + + public async Task ChangePassword(string email, string resetCode, string newPassword) + { + var user = await userManager.FindByEmailAsync(email); + if (user == null) return new FormResult { Succeeded = false, ErrorList = ["User not found"] }; + var result = await userManager.ResetPasswordAsync(user, resetCode, newPassword); + return new FormResult { Succeeded = result.Succeeded, ErrorList = result.Errors.Select(e => e.Description).ToArray() }; + } +} diff --git a/Shogi/FrontEnd/Client/CookieAuthenticationStateProvider.cs b/Shogi/FrontEnd/Client/CookieAuthenticationStateProvider.cs deleted file mode 100644 index 30fc561..0000000 --- a/Shogi/FrontEnd/Client/CookieAuthenticationStateProvider.cs +++ /dev/null @@ -1,276 +0,0 @@ -namespace Shogi.FrontEnd.Client; - -using Microsoft.AspNetCore.Components.Authorization; -using System.Net.Http; -using System.Net.Http.Json; -using System.Security.Claims; -using System.Text; -using System.Text.Json; - -/// -/// Handles state for cookie-based auth. -/// -/// -/// Create a new instance of the auth provider. -/// -/// Factory to retrieve auth client. -public class CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory) : AuthenticationStateProvider, IAccountManagement -{ - /// - /// Map the JavaScript-formatted properties to C#-formatted classes. - /// - private readonly JsonSerializerOptions jsonSerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - - /// - /// Special auth client. - /// - private readonly HttpClient httpClient = httpClientFactory.CreateClient("Auth"); - - /// - /// Authentication state. - /// - private bool authenticated = false; - - /// - /// Default principal for anonymous (not authenticated) users. - /// - private readonly ClaimsPrincipal Unauthenticated = - new(new ClaimsIdentity()); - - /// - /// Register a new user. - /// - /// The user's email address. - /// The user's password. - /// The result serialized to a . - /// - public async Task RegisterAsync(string email, string password) - { - string[] defaultDetail = ["An unknown error prevented registration from succeeding."]; - - try - { - // make the request - var result = await httpClient.PostAsJsonAsync("backend/register", new - { - email, - password - }); - - // successful? - if (result.IsSuccessStatusCode) - { - return new FormResult { Succeeded = true }; - } - - // body should contain details about why it failed - var details = await result.Content.ReadAsStringAsync(); - var problemDetails = JsonDocument.Parse(details); - var errors = new List(); - var errorList = problemDetails.RootElement.GetProperty("errors"); - - foreach (var errorEntry in errorList.EnumerateObject()) - { - if (errorEntry.Value.ValueKind == JsonValueKind.String) - { - errors.Add(errorEntry.Value.GetString()!); - } - else if (errorEntry.Value.ValueKind == JsonValueKind.Array) - { - errors.AddRange( - errorEntry.Value.EnumerateArray().Select( - e => e.GetString() ?? string.Empty) - .Where(e => !string.IsNullOrEmpty(e))); - } - } - - // return the error list - return new FormResult - { - Succeeded = false, - ErrorList = problemDetails == null ? defaultDetail : [.. errors] - }; - } - catch { } - - // unknown error - return new FormResult - { - Succeeded = false, - ErrorList = defaultDetail - }; - } - - /// - /// User login. - /// - /// The user's email address. - /// The user's password. - /// The result of the login request serialized to a . - public async Task LoginAsync(string email, string password) - { - try - { - // login with cookies - var result = await httpClient.PostAsJsonAsync("backend/login?useCookies=true", new - { - email, - password - }); - - // success? - if (result.IsSuccessStatusCode) - { - // need to refresh auth state - NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); - - // success! - return new FormResult { Succeeded = true }; - } - } - catch { } - - // unknown error - return new FormResult - { - Succeeded = false, - ErrorList = ["Invalid email and/or password."] - }; - } - - /// - /// Get authentication state. - /// - /// - /// Called by Blazor anytime and authentication-based decision needs to be made, then cached - /// until the changed state notification is raised. - /// - /// The authentication state asynchronous request. - public override async Task GetAuthenticationStateAsync() - { - authenticated = false; - - // default to not authenticated - var user = Unauthenticated; - - try - { - // the user info endpoint is secured, so if the user isn't logged in this will fail - var userResponse = await httpClient.GetAsync("backend/manage/info"); - - // throw if user info wasn't retrieved - userResponse.EnsureSuccessStatusCode(); - - // user is authenticated,so let's build their authenticated identity - var userJson = await userResponse.Content.ReadAsStringAsync(); - var userInfo = JsonSerializer.Deserialize(userJson, jsonSerializerOptions); - - if (userInfo != null) - { - // in our system name and email are the same - var claims = new List - { - new(ClaimTypes.Name, userInfo.Email), - new(ClaimTypes.Email, userInfo.Email) - }; - - // add any additional claims - claims.AddRange( - userInfo.Claims - .Where(c => c.Key != ClaimTypes.Name && c.Key != ClaimTypes.Email) - .Select(c => new Claim(c.Key, c.Value))); - - // tap the roles endpoint for the user's roles - var rolesResponse = await httpClient.GetAsync("backend/roles"); - - // throw if request fails - rolesResponse.EnsureSuccessStatusCode(); - - // read the response into a string - var rolesJson = await rolesResponse.Content.ReadAsStringAsync(); - - // deserialize the roles string into an array - var roles = JsonSerializer.Deserialize(rolesJson, jsonSerializerOptions); - - // if there are roles, add them to the claims collection - if (roles?.Length > 0) - { - foreach (var role in roles) - { - if (!string.IsNullOrEmpty(role.Type) && !string.IsNullOrEmpty(role.Value)) - { - claims.Add(new Claim(role.Type, role.Value, role.ValueType, role.Issuer, role.OriginalIssuer)); - } - } - } - - // set the principal - var id = new ClaimsIdentity(claims, nameof(CookieAuthenticationStateProvider)); - user = new ClaimsPrincipal(id); - authenticated = true; - } - } - catch { } - - // return the state - return new AuthenticationState(user); - } - - public async Task LogoutAsync() - { - const string Empty = "{}"; - var emptyContent = new StringContent(Empty, Encoding.UTF8, "application/json"); - await httpClient.PostAsync("backend/logout", emptyContent); - NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); - } - - public async Task CheckAuthenticatedAsync() - { - await GetAuthenticationStateAsync(); - return authenticated; - } - - /// - /// Ask for an email to be sent which contains a reset code. This reset code is used during - /// - /// Do not surface errors from this to users which may tell bad actors if emails do or do not exist in the system. - public async Task RequestPasswordReset(string email) - { - return await httpClient.PostAsJsonAsync("backend/forgotPassword", new { email }); - } - - public async Task ChangePassword(string email, string resetCode, string newPassword) - { - var body = new - { - email, - resetCode, - newPassword - }; - var response = await httpClient.PostAsJsonAsync("backend/resetPassword", body); - if (response.IsSuccessStatusCode) - { - return new FormResult { Succeeded = true }; - } - else - { - return new FormResult - { - Succeeded = false, - ErrorList = [await response.Content.ReadAsStringAsync()] - }; - } - } - - public class RoleClaim - { - public string? Issuer { get; set; } - public string? OriginalIssuer { get; set; } - public string? Type { get; set; } - public string? Value { get; set; } - public string? ValueType { get; set; } - } -} diff --git a/Shogi/FrontEnd/Components/App.razor b/Shogi/FrontEnd/Components/App.razor index df9f838..57d6e1f 100644 --- a/Shogi/FrontEnd/Components/App.razor +++ b/Shogi/FrontEnd/Components/App.razor @@ -12,7 +12,14 @@ - + + diff --git a/Shogi/FrontEnd/Components/Layout/NavMenu.razor b/Shogi/FrontEnd/Components/Layout/NavMenu.razor index 9a7047b..d1f400e 100644 --- a/Shogi/FrontEnd/Components/Layout/NavMenu.razor +++ b/Shogi/FrontEnd/Components/Layout/NavMenu.razor @@ -17,7 +17,7 @@ @context.User.Identity?.Name - Logout + Logout Login @@ -53,7 +53,7 @@
  • - Logout + Logout
  • diff --git a/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor b/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor index 1b532c0..dcf0e9c 100644 --- a/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor +++ b/Shogi/FrontEnd/Components/Pages/Identity/LoginPage.razor @@ -1,41 +1,33 @@ @page "/login" -@inject IAccountManagement Acct @inject NavigationManager navigator
    -
    +

    Login

    You're logged in as @context.User.Identity?.Name.
    - @if (errorList.Length > 0) + @if (!string.IsNullOrEmpty(Error)) {
      - @foreach (var error in errorList) - { -
    • @error
    • - } +
    • @Error
    -
    } - + - @if (isEmailSubmitted) - { - - + + - Reset password - } + Reset password - +
    @@ -43,95 +35,6 @@
    @code { - - private string email = string.Empty; - private string password = string.Empty; - private string[] errorList = []; - private System.Threading.CancellationTokenSource? _clearErrorCts; - private bool isEmailSubmitted = false; - private Guid errorKey; - - private async Task HandleSubmit() - { - if (!isEmailSubmitted) - { - SubmitEmail(); - } - else - { - await DoLoginAsync(); - } - } - - private void SubmitEmail() - { - SetErrors([]); - - if (string.IsNullOrWhiteSpace(email)) - { - SetErrors(["Email is required."]); - return; - } - - isEmailSubmitted = true; - } - - public async Task DoLoginAsync() - { - SetErrors([]); - - if (string.IsNullOrWhiteSpace(email)) - { - SetErrors(["Email is required."]); - - return; - } - - if (string.IsNullOrWhiteSpace(password)) - { - SetErrors(["Password is required."]); - - return; - } - - var result = await Acct.LoginAsync(email, password); - - if (result.Succeeded) - { - email = password = string.Empty; - - navigator.NavigateTo(""); - } - else - { - SetErrors(result.ErrorList); - } - } - - private void SetErrors(string[] errors) - { - _clearErrorCts?.Cancel(); - errorList = errors; - - if (errors.Length > 0) - { - errorKey = Guid.NewGuid(); - _clearErrorCts = new System.Threading.CancellationTokenSource(); - _ = ClearErrorsAfterDelay(_clearErrorCts.Token); - } - } - - private async Task ClearErrorsAfterDelay(System.Threading.CancellationToken token) - { - try - { - await Task.Delay(10000, token); - errorList = []; - await InvokeAsync(StateHasChanged); - } - catch (TaskCanceledException) - { - // Ignore - } - } + [SupplyParameterFromQuery] + public string? Error { get; set; } } \ No newline at end of file diff --git a/Shogi/Program.cs b/Shogi/Program.cs index 668ca59..a0d12e8 100644 --- a/Shogi/Program.cs +++ b/Shogi/Program.cs @@ -39,9 +39,8 @@ builder.Services.Configure(builder.Configuration.GetSection("ApiKeys")) builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(sp => sp.GetRequiredService()); -builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); AddIdentity(builder, builder.Configuration); builder.Services.AddSignalR(); diff --git a/Shogi/Properties/launchSettings.json b/Shogi/Properties/launchSettings.json index 98dbd51..b369ea4 100644 --- a/Shogi/Properties/launchSettings.json +++ b/Shogi/Properties/launchSettings.json @@ -1,35 +1,22 @@ { -"profiles": { - "Kestrel": { - "commandName": "Project", - "launchBrowser": true, - "launchUrl": "", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "VaultUri": "https://gameboardshogiuisocketsv.vault.azure.net/", - "AZURE_USERNAME": "Hauth@live.com" + "profiles": { + "Shogi UI": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" - }, - "Swagger": { - "commandName": "Project", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "VaultUri": "https://gameboardshogiuisocketsv.vault.azure.net/", - "AZURE_USERNAME": "Hauth@live.com" - }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" - } -}, - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:50728/", - "sslPort": 44315 + "Swagger UI": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" } - } + }, + "$schema": "http://json.schemastore.org/launchsettings.json" } \ No newline at end of file