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:
246
Shogi.UI/Identity/CookieAuthenticationStateProvider.cs
Normal file
246
Shogi.UI/Identity/CookieAuthenticationStateProvider.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
14
Shogi.UI/Identity/CookieMessageHandler.cs
Normal file
14
Shogi.UI/Identity/CookieMessageHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
14
Shogi.UI/Identity/FormResult.cs
Normal file
14
Shogi.UI/Identity/FormResult.cs
Normal 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; } = [];
|
||||
}
|
||||
31
Shogi.UI/Identity/IAccountManagement.cs
Normal file
31
Shogi.UI/Identity/IAccountManagement.cs
Normal 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();
|
||||
}
|
||||
22
Shogi.UI/Identity/UserInfo.cs
Normal file
22
Shogi.UI/Identity/UserInfo.cs
Normal 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; } = [];
|
||||
}
|
||||
Reference in New Issue
Block a user