diff --git a/.DS_Store b/.DS_Store index 5b17eb53..29e309d4 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Damselfly.Core.DbModels/Models/LoginModel.cs b/Damselfly.Core.DbModels/Models/LoginModel.cs new file mode 100644 index 00000000..27ba896a --- /dev/null +++ b/Damselfly.Core.DbModels/Models/LoginModel.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; + +namespace Damselfly.Core.Models; + +public class LoginResult +{ + public bool Successful { get; set; } + public string Error { get; set; } + public string Token { get; set; } +} + + +public class LoginModel +{ + [Required] + public string Email { get; set; } + + [Required] + public string Password { get; set; } + + public bool RememberMe { get; set; } +} + +public class RegisterModel +{ + [Required] + [EmailAddress] + [Display(Name = "Email")] + public string Email { get; set; } + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "Password")] + public string Password { get; set; } + + [DataType(DataType.Password)] + [Display(Name = "Confirm password")] + [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } +} + +public class RegisterResult +{ + public bool Successful { get; set; } + public IEnumerable Errors { get; set; } +} + +public class UserModel +{ + public string Email { get; set; } + public bool IsAuthenticated { get; set; } +} diff --git a/Damselfly.Core.ScopedServices/Client Services/RestClient.cs b/Damselfly.Core.ScopedServices/Client Services/RestClient.cs index bd7205b7..67142baa 100644 --- a/Damselfly.Core.ScopedServices/Client Services/RestClient.cs +++ b/Damselfly.Core.ScopedServices/Client Services/RestClient.cs @@ -1,4 +1,5 @@ using System; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; @@ -31,6 +32,14 @@ public static void SetJsonOptions(JsonSerializerOptions opts) opts.PropertyNameCaseInsensitive = true; } + public JsonSerializerOptions JsonOptions { get { return jsonOptions; } } + + public AuthenticationHeaderValue AuthHeader + { + get { return _restClient.DefaultRequestHeaders.Authorization; } + set { _restClient.DefaultRequestHeaders.Authorization = value; } + } + private readonly JsonSerializerOptions jsonOptions; private readonly HttpClient _restClient; private readonly ILogger _logger; @@ -60,9 +69,9 @@ public RestClient( HttpClient client, ILogger logger ) } } - public async Task CustomPostAsJsonAsync(string? requestUri, PostObj obj) + public async Task CustomPostAsJsonAsync(string? requestUri, PostObj obj) { - await _restClient.PostAsJsonAsync(requestUri, obj, jsonOptions); + return await _restClient.PostAsJsonAsync(requestUri, obj, jsonOptions); } public async Task CustomPostAsJsonAsync(string? requestUri, PostObj obj) diff --git a/Damselfly.Web.Client/ApiAuthenticationStateProvider.cs b/Damselfly.Web.Client/ApiAuthenticationStateProvider.cs new file mode 100644 index 00000000..635ff146 --- /dev/null +++ b/Damselfly.Web.Client/ApiAuthenticationStateProvider.cs @@ -0,0 +1,97 @@ +using System; + +using Blazored.LocalStorage; +using Microsoft.AspNetCore.Components.Authorization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using System.Threading.Tasks; +using Damselfly.Core.ScopedServices.ClientServices; + +namespace ChrisSaintyExample.Client; + +public class ApiAuthenticationStateProvider : AuthenticationStateProvider +{ + private readonly RestClient _httpClient; + private readonly ILocalStorageService _localStorage; + + public ApiAuthenticationStateProvider(RestClient httpClient, ILocalStorageService localStorage) + { + _httpClient = httpClient; + _localStorage = localStorage; + } + public override async Task GetAuthenticationStateAsync() + { + var savedToken = await _localStorage.GetItemAsync("authToken"); + + if (string.IsNullOrWhiteSpace(savedToken)) + { + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + } + + _httpClient.AuthHeader = new AuthenticationHeaderValue("bearer", savedToken); + + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(savedToken), "jwt"))); + } + + public void MarkUserAsAuthenticated(string email) + { + var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, email) }, "apiauth")); + var authState = Task.FromResult(new AuthenticationState(authenticatedUser)); + NotifyAuthenticationStateChanged(authState); + } + + public void MarkUserAsLoggedOut() + { + var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity()); + var authState = Task.FromResult(new AuthenticationState(anonymousUser)); + NotifyAuthenticationStateChanged(authState); + } + + private IEnumerable ParseClaimsFromJwt(string jwt) + { + var claims = new List(); + var payload = jwt.Split('.')[1]; + var jsonBytes = ParseBase64WithoutPadding(payload); + var keyValuePairs = JsonSerializer.Deserialize>(jsonBytes); + + keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles); + + if (roles != null) + { + if (roles.ToString().Trim().StartsWith("[")) + { + var parsedRoles = JsonSerializer.Deserialize(roles.ToString()); + + foreach (var parsedRole in parsedRoles) + { + claims.Add(new Claim(ClaimTypes.Role, parsedRole)); + } + } + else + { + claims.Add(new Claim(ClaimTypes.Role, roles.ToString())); + } + + keyValuePairs.Remove(ClaimTypes.Role); + } + + claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()))); + + return claims; + } + + private byte[] ParseBase64WithoutPadding(string base64) + { + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + return Convert.FromBase64String(base64); + } +} \ No newline at end of file diff --git a/Damselfly.Web.Client/Damselfly.Web.Client.csproj b/Damselfly.Web.Client/Damselfly.Web.Client.csproj index f3b25463..c0bd0146 100644 --- a/Damselfly.Web.Client/Damselfly.Web.Client.csproj +++ b/Damselfly.Web.Client/Damselfly.Web.Client.csproj @@ -28,6 +28,7 @@ + @@ -59,5 +60,6 @@ + diff --git a/Damselfly.Web.Client/Pages/Login.razor b/Damselfly.Web.Client/Pages/Login.razor new file mode 100644 index 00000000..f7028fd5 --- /dev/null +++ b/Damselfly.Web.Client/Pages/Login.razor @@ -0,0 +1,61 @@ +@page "/login" +@inject IAuthService AuthService +@inject NavigationManager NavigationManager + +@using ChrisSaintyExample.Client.Services; + +

Login

+ +@if (ShowErrors) +{ + +} + +
+
+
Please enter your details
+ + + + +
+ + + +
+
+ + + +
+ +
+
+
+ +@code { + + private LoginModel loginModel = new LoginModel(); + private bool ShowErrors; + private string Error = ""; + + private async Task HandleLogin() + { + ShowErrors = false; + + var result = await AuthService.Login(loginModel); + + if (result.Successful) + { + NavigationManager.NavigateTo("/"); + } + else + { + Error = result.Error; + ShowErrors = true; + } + } + +} \ No newline at end of file diff --git a/Damselfly.Web.Client/Pages/Logout.razor b/Damselfly.Web.Client/Pages/Logout.razor new file mode 100644 index 00000000..22c72e03 --- /dev/null +++ b/Damselfly.Web.Client/Pages/Logout.razor @@ -0,0 +1,15 @@ +@page "/logout" +@inject IAuthService AuthService +@inject NavigationManager NavigationManager +@using ChrisSaintyExample.Client.Services; + + +@code { + + protected override async Task OnInitializedAsync() + { + await AuthService.Logout(); + NavigationManager.NavigateTo("/"); + } + +} \ No newline at end of file diff --git a/Damselfly.Web.Client/Pages/Register.razor b/Damselfly.Web.Client/Pages/Register.razor new file mode 100644 index 00000000..173856d7 --- /dev/null +++ b/Damselfly.Web.Client/Pages/Register.razor @@ -0,0 +1,68 @@ +@page "/register" +@inject IAuthService AuthService +@inject NavigationManager NavigationManager +@using ChrisSaintyExample.Client.Services; + +

Register

+ +@if (ShowErrors) +{ + +} + +
+
+
Please enter your details
+ + + + +
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+
+ +@code { + + private RegisterModel RegisterModel = new RegisterModel(); + private bool ShowErrors; + private IEnumerable Errors; + + private async Task HandleRegistration() + { + ShowErrors = false; + + var result = await AuthService.Register(RegisterModel); + + if (result.Successful) + { + NavigationManager.NavigateTo("/login"); + } + else + { + Errors = result.Errors; + ShowErrors = true; + } + } + +} \ No newline at end of file diff --git a/Damselfly.Web.Client/Program.cs b/Damselfly.Web.Client/Program.cs index 18f6ea31..ffd2d4d4 100644 --- a/Damselfly.Web.Client/Program.cs +++ b/Damselfly.Web.Client/Program.cs @@ -13,6 +13,10 @@ using Syncfusion.Blazor; using Microsoft.Extensions.Options; using Syncfusion.Licensing; +using Blazored.LocalStorage; +using ChrisSaintyExample.Client; +using ChrisSaintyExample.Client.Services; +using Microsoft.AspNetCore.Components.Authorization; namespace Damselfly.Web.Client; @@ -35,15 +39,18 @@ public static async Task Main(string[] args) builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("DamselflyAPI")); builder.Services.AddMemoryCache(x => x.SizeLimit = 500); - builder.Services.AddApiAuthorization(); builder.Services.AddAuthorizationCore(config => config.SetupPolicies(builder.Services)); builder.Services.AddMudServices(); builder.Services.AddSyncfusionBlazor(options => { options.IgnoreScriptIsolation = true; }); + builder.Services.AddBlazoredLocalStorage(); builder.Services.AddScoped(); builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddDamselflyUIServices(); await builder.Build().RunAsync(); diff --git a/Damselfly.Web.Client/Services/AuthService.cs b/Damselfly.Web.Client/Services/AuthService.cs new file mode 100644 index 00000000..0c28e2b8 --- /dev/null +++ b/Damselfly.Web.Client/Services/AuthService.cs @@ -0,0 +1,61 @@ +using Blazored.LocalStorage; +using Damselfly.Core.Models; +using Damselfly.Core.ScopedServices.ClientServices; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace ChrisSaintyExample.Client.Services; + +public class AuthService : IAuthService +{ + private readonly RestClient _httpClient; + private readonly AuthenticationStateProvider _authenticationStateProvider; + private readonly ILocalStorageService _localStorage; + + public AuthService(RestClient httpClient, + AuthenticationStateProvider authenticationStateProvider, + ILocalStorageService localStorage) + { + _httpClient = httpClient; + _authenticationStateProvider = authenticationStateProvider; + _localStorage = localStorage; + } + + public async Task Register(RegisterModel registerModel) + { + var result = await _httpClient.CustomPostAsJsonAsync("api/accounts", registerModel); + return result; + } + + public async Task Login(LoginModel loginModel) + { + var response = await _httpClient.CustomPostAsJsonAsync("api/Login", loginModel); + var loginResult = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(), _httpClient.JsonOptions ); + + if (!response.IsSuccessStatusCode) + { + loginResult.Error = "Error logging in."; + return loginResult; + } + + await _localStorage.SetItemAsync("authToken", loginResult.Token); + ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsAuthenticated(loginModel.Email); + _httpClient.AuthHeader = new AuthenticationHeaderValue("bearer", loginResult.Token); + + return loginResult; + } + + public async Task Logout() + { + await _localStorage.RemoveItemAsync("authToken"); + ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsLoggedOut(); + _httpClient.AuthHeader = null; + } +} \ No newline at end of file diff --git a/Damselfly.Web.Client/Services/IAuthService.cs b/Damselfly.Web.Client/Services/IAuthService.cs new file mode 100644 index 00000000..0c67627d --- /dev/null +++ b/Damselfly.Web.Client/Services/IAuthService.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading.Tasks; +using Damselfly.Core.Models; + +namespace ChrisSaintyExample.Client.Services; + +public interface IAuthService +{ + Task Login(LoginModel loginModel); + Task Logout(); + Task Register(RegisterModel registerModel); +} \ No newline at end of file diff --git a/Damselfly.Web.Server/Controllers/AccountsController.cs b/Damselfly.Web.Server/Controllers/AccountsController.cs new file mode 100644 index 00000000..bb4fa4c0 --- /dev/null +++ b/Damselfly.Web.Server/Controllers/AccountsController.cs @@ -0,0 +1,37 @@ +using Damselfly.Core.DbModels; +using Damselfly.Core.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using System.Linq; +using System.Threading.Tasks; + +namespace AuthenticationWithClientSideBlazor.Server.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class AccountsController : ControllerBase +{ + private readonly UserManager _userManager; + + public AccountsController(UserManager userManager) + { + _userManager = userManager; + } + + [HttpPost] + public async Task Post([FromBody] RegisterModel model) + { + var newUser = new AppIdentityUser { UserName = model.Email, Email = model.Email }; + + var result = await _userManager.CreateAsync(newUser, model.Password); + + if (!result.Succeeded) + { + var errors = result.Errors.Select(x => x.Description); + + return Ok(new RegisterResult { Successful = false, Errors = errors }); + } + + return Ok(new RegisterResult { Successful = true }); + } +} \ No newline at end of file diff --git a/Damselfly.Web.Server/Controllers/LoginController.cs b/Damselfly.Web.Server/Controllers/LoginController.cs new file mode 100644 index 00000000..508ca98a --- /dev/null +++ b/Damselfly.Web.Server/Controllers/LoginController.cs @@ -0,0 +1,61 @@ +using Damselfly.Core.DbModels; +using Damselfly.Core.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace ChrisSaintyExample.Server.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class LoginController : ControllerBase +{ + private readonly IConfiguration _configuration; + private readonly SignInManager _signInManager; + + public LoginController(IConfiguration configuration, + SignInManager signInManager) + { + _configuration = configuration; + _signInManager = signInManager; + } + + [HttpPost] + public async Task Login([FromBody] LoginModel login) + { + var result = await _signInManager.PasswordSignInAsync(login.Email, login.Password, false, false); + + if (!result.Succeeded) return BadRequest(new LoginResult { Successful = false, Error = "Username and password are invalid." }); + + var user = await _signInManager.UserManager.FindByEmailAsync(login.Email); + var roles = await _signInManager.UserManager.GetRolesAsync(user); + var claims = new List(); + + claims.Add(new Claim(ClaimTypes.Name, login.Email)); + + foreach (var role in roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("BlahSomeKeyBlahFlibbertyGibbertNonsenseBananarama")); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expiry = DateTime.Now.AddDays(Convert.ToInt32(1)); + + var token = new JwtSecurityToken( + "https://localhost", + "https://localhost", + claims, + expires: expiry, + signingCredentials: creds + ); + + return Ok(new LoginResult { Successful = true, Token = new JwtSecurityTokenHandler().WriteToken(token) }); + } +} \ No newline at end of file diff --git a/Damselfly.Web.Server/Program.cs b/Damselfly.Web.Server/Program.cs index 36b4fe46..2b541cb4 100644 --- a/Damselfly.Web.Server/Program.cs +++ b/Damselfly.Web.Server/Program.cs @@ -29,6 +29,9 @@ using Microsoft.AspNetCore.ApiAuthorization.IdentityServer; using Syncfusion.Licensing; using Damselfly.Core.ScopedServices.ClientServices; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; namespace Damselfly.Web; @@ -192,20 +195,31 @@ private static void StartWebServer(int listeningPort, string[] args) // Add services to the container. var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); - builder.Services.AddDbContext(options => + builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); - builder.Services.AddDefaultIdentity(options => options.SignIn.RequireConfirmedAccount = true) - .AddEntityFrameworkStores(); - - builder.Services.AddIdentityServer() - .AddApiAuthorization(); - - builder.Services.AddAuthorization(config => config.SetupPolicies(builder.Services)); - - builder.Services.AddAuthentication() - .AddIdentityServerJwt(); + builder.Services.AddDefaultIdentity(options => options.SignIn.RequireConfirmedAccount = false) + .AddRoles() + .AddEntityFrameworkStores(); + + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = "https://localhost", + ValidAudience = "https://localhost", + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("BlahSomeKeyBlahFlibbertyGibbertNonsenseBananarama")) + }; + }); + + // WASM: TODO + // builder.Services.AddAuthorization(config => config.SetupPolicies(builder.Services)); // Cache up to 10,000 images. Should be enough given cache expiry. builder.Services.AddMemoryCache(x => x.SizeLimit = 5000); diff --git a/Damselfly.Web/Properties/launchSettings.json b/Damselfly.Web/Properties/launchSettings.json index c5c5fc93..ff9ee179 100644 --- a/Damselfly.Web/Properties/launchSettings.json +++ b/Damselfly.Web/Properties/launchSettings.json @@ -18,7 +18,7 @@ "Damselfly.Web": { "commandName": "Project", "launchBrowser": true, - "applicationUrl": "http://localhost:40950", + "applicationUrl": "http://localhost:29120", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }