Replace custom socket implementation with SignalR.

Replace MSAL and custom cookie auth with Microsoft.Identity.EntityFramework
Also some UI redesign to accommodate different login experience.
This commit is contained in:
2024-08-25 03:46:44 +00:00
parent d688afaeae
commit 51d234d871
172 changed files with 3857 additions and 4045 deletions

View File

@@ -0,0 +1,246 @@
namespace Shogi.UI.Identity;
using Microsoft.AspNetCore.Components.Authorization;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
/// <summary>
/// Handles state for cookie-based auth.
/// </summary>
public class CookieAuthenticationStateProvider : 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;
/// <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>
/// Create a new instance of the auth provider.
/// </summary>
/// <param name="httpClientFactory">Factory to retrieve auth client.</param>
public CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory)
=> _httpClient = httpClientFactory.CreateClient("Auth");
/// <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("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("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("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("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("logout", emptyContent);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public async Task<bool> CheckAuthenticatedAsync()
{
await GetAuthenticationStateAsync();
return _authenticated;
}
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

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Components.WebAssembly.Http;
namespace Shogi.UI.Identity;
public class CookieCredentialsMessageHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
request.Headers.Add("X-Requested-With", ["XMLHttpRequest"]);
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,14 @@
namespace Shogi.UI.Identity;
public class FormResult
{
/// <summary>
/// Gets or sets a value indicating whether the action was successful.
/// </summary>
public bool Succeeded { get; set; }
/// <summary>
/// On failure, the problem details are parsed and returned in this array.
/// </summary>
public string[] ErrorList { get; set; } = [];
}

View File

@@ -0,0 +1,31 @@
namespace Shogi.UI.Identity;
/// <summary>
/// Account management services.
/// </summary>
public interface IAccountManagement
{
/// <summary>
/// Login service.
/// </summary>
/// <param name="email">User's email.</param>
/// <param name="password">User's password.</param>
/// <returns>The result of the request serialized to <see cref="FormResult"/>.</returns>
public Task<FormResult> LoginAsync(string email, string password);
/// <summary>
/// Log out the logged in user.
/// </summary>
/// <returns>The asynchronous task.</returns>
public Task LogoutAsync();
/// <summary>
/// Registration service.
/// </summary>
/// <param name="email">User's email.</param>
/// <param name="password">User's password.</param>
/// <returns>The result of the request serialized to <see cref="FormResult"/>.</returns>
public Task<FormResult> RegisterAsync(string email, string password);
public Task<bool> CheckAuthenticatedAsync();
}

View File

@@ -0,0 +1,22 @@
namespace Shogi.UI.Identity;
/// <summary>
/// User info from identity endpoint to establish claims.
/// </summary>
public class UserInfo
{
/// <summary>
/// The email address.
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// A value indicating whether the email has been confirmed yet.
/// </summary>
public bool IsEmailConfirmed { get; set; }
/// <summary>
/// The list of claims for the user.
/// </summary>
public Dictionary<string, string> Claims { get; set; } = [];
}