namespace Shogi.UI.Identity; 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. /// public class CookieAuthenticationStateProvider : 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; /// /// Authentication state. /// private bool _authenticated = false; /// /// Default principal for anonymous (not authenticated) users. /// private readonly ClaimsPrincipal Unauthenticated = new(new ClaimsIdentity()); /// /// Create a new instance of the auth provider. /// /// Factory to retrieve auth client. public CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory) => _httpClient = httpClientFactory.CreateClient("Auth"); /// /// 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("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("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("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("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("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("forgotPassword", new { email }); } public async Task ChangePassword(string email, string resetCode, string newPassword) { var body = new { email, resetCode, newPassword }; var response = await _httpClient.PostAsJsonAsync("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; } } }