This commit is contained in:
2026-01-16 17:14:03 -06:00
parent 2eba8cdb0e
commit 78733295e0
8 changed files with 129 additions and 429 deletions

View File

@@ -60,21 +60,29 @@ public class AccountController(
} }
} }
return this.Ok(); return this.NoContent();
} }
[HttpPost("/backend/logout")] [HttpPost("Login")]
public async Task<IActionResult> Logout([FromBody] object empty) [AllowAnonymous]
public async Task<IActionResult> Login([FromForm] string email, [FromForm] string password)
{ {
// https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support var result = await signInManager.PasswordSignInAsync(email, password, isPersistent: true, lockoutOnFailure: false);
if (empty is not null) if (result.Succeeded)
{
return Redirect("/");
}
return Redirect("/login?error=Invalid login attempt.");
}
[HttpGet("Logout")]
[HttpPost("Logout")]
[AllowAnonymous]
public async Task<IActionResult> Logout()
{ {
await signInManager.SignOutAsync(); await signInManager.SignOutAsync();
return Redirect("/");
return this.Ok();
}
return this.Unauthorized();
} }
[HttpGet("/backend/roles")] [HttpGet("/backend/roles")]

View File

@@ -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<ShogiUser> userManager,
IHttpContextAccessor httpContextAccessor,
IEmailSender emailSender) : IAccountManagement
{
public async Task<FormResult> 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 <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
return new FormResult { Succeeded = true };
}
return new FormResult { Succeeded = false, ErrorList = result.Errors.Select(e => e.Description).ToArray() };
}
public Task<FormResult> 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<bool> CheckAuthenticatedAsync()
{
return Task.FromResult(httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false);
}
public async Task<HttpResponseMessage> 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<FormResult> 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() };
}
}

View File

@@ -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;
/// <summary>
/// Handles state for cookie-based auth.
/// </summary>
/// <remarks>
/// Create a new instance of the auth provider.
/// </remarks>
/// <param name="httpClientFactory">Factory to retrieve auth client.</param>
public class CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory) : AuthenticationStateProvider, IAccountManagement
{
/// <summary>
/// Map the JavaScript-formatted properties to C#-formatted classes.
/// </summary>
private readonly JsonSerializerOptions jsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// Special auth client.
/// </summary>
private readonly HttpClient httpClient = httpClientFactory.CreateClient("Auth");
/// <summary>
/// Authentication state.
/// </summary>
private bool authenticated = false;
/// <summary>
/// Default principal for anonymous (not authenticated) users.
/// </summary>
private readonly ClaimsPrincipal Unauthenticated =
new(new ClaimsIdentity());
/// <summary>
/// Register a new user.
/// </summary>
/// <param name="email">The user's email address.</param>
/// <param name="password">The user's password.</param>
/// <returns>The result serialized to a <see cref="FormResult"/>.
/// </returns>
public async Task<FormResult> 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<string>();
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
};
}
/// <summary>
/// User login.
/// </summary>
/// <param name="email">The user's email address.</param>
/// <param name="password">The user's password.</param>
/// <returns>The result of the login request serialized to a <see cref="FormResult"/>.</returns>
public async Task<FormResult> 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."]
};
}
/// <summary>
/// Get authentication state.
/// </summary>
/// <remarks>
/// Called by Blazor anytime and authentication-based decision needs to be made, then cached
/// until the changed state notification is raised.
/// </remarks>
/// <returns>The authentication state asynchronous request.</returns>
public override async Task<AuthenticationState> 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<UserInfo>(userJson, jsonSerializerOptions);
if (userInfo != null)
{
// in our system name and email are the same
var claims = new List<Claim>
{
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<RoleClaim[]>(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<bool> CheckAuthenticatedAsync()
{
await GetAuthenticationStateAsync();
return authenticated;
}
/// <summary>
/// Ask for an email to be sent which contains a reset code. This reset code is used during <see cref="ChangePassword"/>
/// </summary>
/// <remarks>Do not surface errors from this to users which may tell bad actors if emails do or do not exist in the system.</remarks>
public async Task<HttpResponseMessage> RequestPasswordReset(string email)
{
return await httpClient.PostAsJsonAsync("backend/forgotPassword", new { email });
}
public async Task<FormResult> 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; }
}
}

View File

@@ -12,7 +12,14 @@
<HeadOutlet @rendermode="RenderMode.InteractiveServer" /> <HeadOutlet @rendermode="RenderMode.InteractiveServer" />
</head> </head>
<body> <!--
data-enhance:
Enables Blazor's "Enhanced Form Handling" globally for the document body.
This intercepts standard <form> POST requests (like our login implementation) and performs them via fetch,
patching the DOM with the response instead of triggering a full page reload.
This provides a SPA-like experience even when using server-side endpoints for Cookies/Auth.
-->
<body data-enhance>
<Routes @rendermode="RenderMode.InteractiveServer" /> <Routes @rendermode="RenderMode.InteractiveServer" />
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
</body> </body>

View File

@@ -17,7 +17,7 @@
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<span>@context.User.Identity?.Name</span> <span>@context.User.Identity?.Name</span>
<a href="logout">Logout</a> <a href="backend/Account/Logout">Logout</a>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<a href="login">Login</a> <a href="login">Login</a>
@@ -53,7 +53,7 @@
<button class="href" @onclick="CreateSession">Create</button> <button class="href" @onclick="CreateSession">Create</button>
</li> </li>
<li> <li>
<a href="logout">Logout</a> <a href="backend/Account/Logout">Logout</a>
</li> </li>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>

View File

@@ -1,41 +1,33 @@
@page "/login" @page "/login"
@inject IAccountManagement Acct
@inject NavigationManager navigator @inject NavigationManager navigator
<main class="PrimaryTheme"> <main class="PrimaryTheme">
<form class="LoginForm" @onsubmit="HandleSubmit"> <form class="LoginForm" method="post" action="backend/Account/Login">
<h1 style="grid-area: h1">Login</h1> <h1 style="grid-area: h1">Login</h1>
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<div>You're logged in as @context.User.Identity?.Name.</div> <div>You're logged in as @context.User.Identity?.Name.</div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@if (errorList.Length > 0) @if (!string.IsNullOrEmpty(Error))
{ {
<div class="Errors" style="grid-area: errors"> <div class="Errors" style="grid-area: errors">
<ul> <ul>
@foreach (var error in errorList) <li>@Error</li>
{
<li>@error</li>
}
</ul> </ul>
<div class="ErrorProgress" @key="errorKey"></div>
</div> </div>
} }
<label for="email" style="grid-area: emailLabel">Email</label> <label for="email" style="grid-area: emailLabel">Email</label>
<input required id="email" name="emailInput" type="text" style="grid-area: emailControl" @bind-value="email" readonly="@isEmailSubmitted" /> <input required id="email" name="email" type="text" style="grid-area: emailControl" />
@if (isEmailSubmitted)
{
<label for="password" style="grid-area: passLabel">Password</label> <label for="password" style="grid-area: passLabel">Password</label>
<input required id="password" name="passwordInput" type="password" style="grid-area: passControl" @bind-value="password" /> <input required id="password" name="password" type="password" style="grid-area: passControl" />
<a href="forgot" style="grid-area: resetLink; place-self: end;">Reset password</a> <a href="forgot" style="grid-area: resetLink; place-self: end;">Reset password</a>
}
<button style="grid-area: button" type="submit">@(isEmailSubmitted ? "Login" : "Next")</button> <button style="grid-area: button" type="submit">Login</button>
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </AuthorizeView>
</form> </form>
@@ -43,95 +35,6 @@
</main> </main>
@code { @code {
[SupplyParameterFromQuery]
private string email = string.Empty; public string? Error { get; set; }
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
}
}
} }

View File

@@ -39,9 +39,8 @@ builder.Services.Configure<ApiKeys>(builder.Configuration.GetSection("ApiKeys"))
builder.Services.AddTransient<ILocalStorage, LocalStorage>(); builder.Services.AddTransient<ILocalStorage, LocalStorage>();
builder.Services.AddScoped<ShogiService>(); builder.Services.AddScoped<ShogiService>();
builder.Services.AddScoped<GameHubNode>(); builder.Services.AddScoped<GameHubNode>();
builder.Services.AddScoped<CookieAuthenticationStateProvider>(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<CookieAuthenticationStateProvider>()); builder.Services.AddScoped<IAccountManagement, ServerAccountManager>();
builder.Services.AddScoped<IAccountManagement>(sp => sp.GetRequiredService<CookieAuthenticationStateProvider>());
AddIdentity(builder, builder.Configuration); AddIdentity(builder, builder.Configuration);
builder.Services.AddSignalR(); builder.Services.AddSignalR();

View File

@@ -1,35 +1,22 @@
{ {
"profiles": { "profiles": {
"Kestrel": { "Shogi UI": {
"commandName": "Project", "commandName": "Project",
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_ENVIRONMENT": "Development"
"VaultUri": "https://gameboardshogiuisocketsv.vault.azure.net/",
"AZURE_USERNAME": "Hauth@live.com"
}, },
"applicationUrl": "https://localhost:5001;http://localhost:5000" "applicationUrl": "https://localhost:5001;http://localhost:5000"
}, },
"Swagger": { "Swagger UI": {
"commandName": "Project", "commandName": "Project",
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "swagger", "launchUrl": "swagger",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_ENVIRONMENT": "Development"
"VaultUri": "https://gameboardshogiuisocketsv.vault.azure.net/",
"AZURE_USERNAME": "Hauth@live.com"
}, },
"applicationUrl": "https://localhost:5001;http://localhost:5000" "applicationUrl": "https://localhost:5001;http://localhost:5000"
} }
}, },
"$schema": "http://json.schemastore.org/launchsettings.json", "$schema": "http://json.schemastore.org/launchsettings.json"
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:50728/",
"sslPort": 44315
}
}
} }