diff --git a/src/GameIdeas/Client/GameIdeas.BlazorApp/GameIdeas.BlazorApp.csproj b/src/GameIdeas/Client/GameIdeas.BlazorApp/GameIdeas.BlazorApp.csproj index 0388002..79da7a0 100644 --- a/src/GameIdeas/Client/GameIdeas.BlazorApp/GameIdeas.BlazorApp.csproj +++ b/src/GameIdeas/Client/GameIdeas.BlazorApp/GameIdeas.BlazorApp.csproj @@ -9,10 +9,13 @@ + + + diff --git a/src/GameIdeas/Client/GameIdeas.BlazorApp/Pages/User/Gateways/AuthGateway.cs b/src/GameIdeas/Client/GameIdeas.BlazorApp/Pages/User/Gateways/AuthGateway.cs new file mode 100644 index 0000000..f19366e --- /dev/null +++ b/src/GameIdeas/Client/GameIdeas.BlazorApp/Pages/User/Gateways/AuthGateway.cs @@ -0,0 +1,37 @@ +using GameIdeas.BlazorApp.Services; +using GameIdeas.BlazorApp.Shared.Constants; +using GameIdeas.BlazorApp.Shared.Exceptions; +using GameIdeas.Resources; +using GameIdeas.Shared.Dto; + +namespace GameIdeas.BlazorApp.Pages.User.Gateways; + +public class AuthGateway(IHttpClientService httpClient, + JwtAuthenticationStateProvider stateProvider) : IAuthGateway +{ + public async Task Login(UserDto userDto) + { + try + { + var token = await httpClient.PostAsync(Endpoints.Auth.Login, userDto); + await stateProvider.NotifyUserAuthenticationAsync(token!.Token!); + return true; + } + catch (Exception) + { + throw new AuthenticationUserException(ResourcesKey.UserLoginFailed); + } + } + + public async Task Logout() + { + try + { + await stateProvider.NotifyUserLogoutAsync(); + } + catch (Exception) + { + throw new AuthenticationUserException(ResourcesKey.UserLogoutFailed); + } + } +} diff --git a/src/GameIdeas/Client/GameIdeas.BlazorApp/Pages/User/Gateways/IAuthGateway.cs b/src/GameIdeas/Client/GameIdeas.BlazorApp/Pages/User/Gateways/IAuthGateway.cs new file mode 100644 index 0000000..c3d2620 --- /dev/null +++ b/src/GameIdeas/Client/GameIdeas.BlazorApp/Pages/User/Gateways/IAuthGateway.cs @@ -0,0 +1,9 @@ +using GameIdeas.Shared.Dto; + +namespace GameIdeas.BlazorApp.Pages.User.Gateways; + +public interface IAuthGateway +{ + Task Login(UserDto userDto); + Task Logout(); +} diff --git a/src/GameIdeas/Client/GameIdeas.BlazorApp/Program.cs b/src/GameIdeas/Client/GameIdeas.BlazorApp/Program.cs index 98b893b..60ff0f6 100644 --- a/src/GameIdeas/Client/GameIdeas.BlazorApp/Program.cs +++ b/src/GameIdeas/Client/GameIdeas.BlazorApp/Program.cs @@ -1,12 +1,16 @@ using System.Net.Http.Json; +using Blazored.LocalStorage; using GameIdeas.BlazorApp; using GameIdeas.BlazorApp.Pages.Games.Gateways; +using GameIdeas.BlazorApp.Pages.User.Gateways; using GameIdeas.BlazorApp.Services; using GameIdeas.Resources; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; var builder = WebAssemblyHostBuilder.CreateDefault(args); +var services = builder.Services; + builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); @@ -15,7 +19,7 @@ UriBuilder uriBuilder = new(builder.HostEnvironment.BaseAddress) Port = 8000 }; -builder.Services.AddHttpClient( +services.AddHttpClient( "GameIdeas.WebAPI", client => { @@ -23,11 +27,18 @@ builder.Services.AddHttpClient( client.Timeout = TimeSpan.FromMinutes(3); }); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +services.AddBlazoredLocalStorage(); +services.AddAuthorizationCore(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +services.AddScoped(); + +services.AddScoped(); + +services.AddScoped(); +services.AddScoped(); + +services.AddSingleton(); +services.AddSingleton(); var app = builder.Build(); diff --git a/src/GameIdeas/Client/GameIdeas.BlazorApp/Services/JwtAuthenticationStateProvider.cs b/src/GameIdeas/Client/GameIdeas.BlazorApp/Services/JwtAuthenticationStateProvider.cs new file mode 100644 index 0000000..2417dac --- /dev/null +++ b/src/GameIdeas/Client/GameIdeas.BlazorApp/Services/JwtAuthenticationStateProvider.cs @@ -0,0 +1,47 @@ +using Blazored.LocalStorage; +using Microsoft.AspNetCore.Components.Authorization; +using System.Security.Claims; +using System.IdentityModel.Tokens.Jwt; + +namespace GameIdeas.BlazorApp.Services; + +public class JwtAuthenticationStateProvider(ILocalStorageService localStorage) : AuthenticationStateProvider +{ + public override async Task GetAuthenticationStateAsync() + { + var savedToken = await localStorage.GetItemAsStringAsync("authToken"); + + if (!string.IsNullOrWhiteSpace(savedToken)) + { + try + { + var token = new JwtSecurityTokenHandler().ReadJwtToken(savedToken); + + return new AuthenticationState( + new ClaimsPrincipal(new ClaimsIdentity(token.Claims, "jwt")) + ); + } + catch + { + await localStorage.RemoveItemAsync("authToken"); + } + } + + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + } + + public async Task NotifyUserAuthenticationAsync(string token) + { + await localStorage.SetItemAsStringAsync("authToken", token); + + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + public async Task NotifyUserLogoutAsync() + { + await localStorage.RemoveItemAsync("authToken"); + + var nobody = new ClaimsPrincipal(new ClaimsIdentity()); + NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(nobody))); + } +} diff --git a/src/GameIdeas/Client/GameIdeas.BlazorApp/Shared/Constants/Endpoints.cs b/src/GameIdeas/Client/GameIdeas.BlazorApp/Shared/Constants/Endpoints.cs index 0665ffa..0c45383 100644 --- a/src/GameIdeas/Client/GameIdeas.BlazorApp/Shared/Constants/Endpoints.cs +++ b/src/GameIdeas/Client/GameIdeas.BlazorApp/Shared/Constants/Endpoints.cs @@ -15,4 +15,9 @@ public static class Endpoints { public static readonly string AllCategories = "api/Category/All"; } + + public static class Auth + { + public static readonly string Login = "api/User/Login"; + } } diff --git a/src/GameIdeas/Client/GameIdeas.BlazorApp/Shared/Exceptions/AuthenticationUserException.cs b/src/GameIdeas/Client/GameIdeas.BlazorApp/Shared/Exceptions/AuthenticationUserException.cs new file mode 100644 index 0000000..cc07334 --- /dev/null +++ b/src/GameIdeas/Client/GameIdeas.BlazorApp/Shared/Exceptions/AuthenticationUserException.cs @@ -0,0 +1,3 @@ +namespace GameIdeas.BlazorApp.Shared.Exceptions; + +public class AuthenticationUserException(string message) : Exception(message); diff --git a/src/GameIdeas/GameIdeas.Resources/CreateStaticResourceKey.cs b/src/GameIdeas/GameIdeas.Resources/CreateStaticResourceKey.cs index 2f8b722..f6deabc 100644 --- a/src/GameIdeas/GameIdeas.Resources/CreateStaticResourceKey.cs +++ b/src/GameIdeas/GameIdeas.Resources/CreateStaticResourceKey.cs @@ -50,6 +50,9 @@ public class Translations (TranslationService translationService) public string MinMaxStorageSpaceFormat => translationService.Translate(nameof(MinMaxStorageSpaceFormat)); public string UserArgumentsNull => translationService.Translate(nameof(UserArgumentsNull)); public string InvalidToken => translationService.Translate(nameof(InvalidToken)); + public string UserUnauthorized => translationService.Translate(nameof(UserUnauthorized)); + public string UserLoginFailed => translationService.Translate(nameof(UserLoginFailed)); + public string UserLogoutFailed => translationService.Translate(nameof(UserLogoutFailed)); } public static class ResourcesKey @@ -108,4 +111,7 @@ public static class ResourcesKey public static string MinMaxStorageSpaceFormat => _instance?.MinMaxStorageSpaceFormat ?? throw new InvalidOperationException("ResourcesKey.MinMaxStorageSpaceFormat is not initialized."); public static string UserArgumentsNull => _instance?.UserArgumentsNull ?? throw new InvalidOperationException("ResourcesKey.UserArgumentsNull is not initialized."); public static string InvalidToken => _instance?.InvalidToken ?? throw new InvalidOperationException("ResourcesKey.InvalidToken is not initialized."); + public static string UserUnauthorized => _instance?.UserUnauthorized ?? throw new InvalidOperationException("ResourcesKey.UserUnauthorized is not initialized."); + public static string UserLoginFailed => _instance?.UserLoginFailed ?? throw new InvalidOperationException("ResourcesKey.UserLoginFailed is not initialized."); + public static string UserLogoutFailed => _instance?.UserLogoutFailed ?? throw new InvalidOperationException("ResourcesKey.UserLogoutFailed is not initialized."); } \ No newline at end of file diff --git a/src/GameIdeas/Server/GameIdeas.WebAPI/Controllers/UserController.cs b/src/GameIdeas/Server/GameIdeas.WebAPI/Controllers/UserController.cs index 23049f8..ca35ecc 100644 --- a/src/GameIdeas/Server/GameIdeas.WebAPI/Controllers/UserController.cs +++ b/src/GameIdeas/Server/GameIdeas.WebAPI/Controllers/UserController.cs @@ -1,54 +1,37 @@ -using GameIdeas.Resources; -using GameIdeas.Shared.Constants; -using GameIdeas.Shared.Dto; -using GameIdeas.Shared.Model; -using Microsoft.AspNetCore.Identity; +using GameIdeas.Shared.Dto; +using GameIdeas.WebAPI.Exceptions; +using GameIdeas.WebAPI.Services.Users; using Microsoft.AspNetCore.Mvc; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; namespace GameIdeas.WebAPI.Controllers; -public class UserController(UserManager userManager) : Controller +public class UserController( + IUserService userService, + ILoggerFactory loggerFactory) : Controller { + private readonly ILogger logger = loggerFactory.CreateLogger(); + [HttpPost("login")] public async Task> Login([FromBody] UserDto model) { - if (model.Username == null || model.Password == null) - throw new ArgumentNullException(paramName: nameof(model), ResourcesKey.UserArgumentsNull); - - var user = await userManager.FindByNameAsync(model.Username); - - if (user != null && await userManager.CheckPasswordAsync(user, model.Password)) - { - List authClaims = - [ - new Claim(ClaimTypes.Name, user.UserName ?? string.Empty), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - ]; - - var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY") - ?? throw new ArgumentNullException(message: ResourcesKey.InvalidToken, null); - - var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); - - var token = new JwtSecurityToken( - issuer: Environment.GetEnvironmentVariable("JWT_ISSUER"), - audience: Environment.GetEnvironmentVariable("JWT_AUDIENCE"), - expires: DateTime.Now.AddHours(GlobalConstants.JWT_DURATION_HOUR), - claims: authClaims, - signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) - ); - - return Ok(new TokenDto - { - Token = new JwtSecurityTokenHandler().WriteToken(token), - Expiration = token.ValidTo - }); + try + { + return Ok(await userService.Login(model)); + } + catch (UserInvalidException e) + { + logger.LogInformation(e, "Missing informations for authentication"); + return StatusCode(406, e.Message); + } + catch (UserUnauthorizedException e) + { + logger.LogWarning(e, "Authentication invalid with there informations"); + return Unauthorized(e.Message); + } + catch (Exception e) + { + logger.LogError(e, "Internal error while search games"); + return StatusCode(500, e.Message); } - - return Unauthorized(); } } diff --git a/src/GameIdeas/Server/GameIdeas.WebAPI/Exceptions/UserInvalidException.cs b/src/GameIdeas/Server/GameIdeas.WebAPI/Exceptions/UserInvalidException.cs new file mode 100644 index 0000000..a1ddfca --- /dev/null +++ b/src/GameIdeas/Server/GameIdeas.WebAPI/Exceptions/UserInvalidException.cs @@ -0,0 +1,3 @@ +namespace GameIdeas.WebAPI.Exceptions; + +public class UserInvalidException (string message) : Exception(message); diff --git a/src/GameIdeas/Server/GameIdeas.WebAPI/Exceptions/UserUnauthorizedException.cs b/src/GameIdeas/Server/GameIdeas.WebAPI/Exceptions/UserUnauthorizedException.cs new file mode 100644 index 0000000..95006e1 --- /dev/null +++ b/src/GameIdeas/Server/GameIdeas.WebAPI/Exceptions/UserUnauthorizedException.cs @@ -0,0 +1,3 @@ +namespace GameIdeas.WebAPI.Exceptions; + +public class UserUnauthorizedException(string message) : Exception(message); diff --git a/src/GameIdeas/Server/GameIdeas.WebAPI/Files/GameIdeas.fr.json b/src/GameIdeas/Server/GameIdeas.WebAPI/Files/GameIdeas.fr.json index e2f9a4e..4eaf51b 100644 --- a/src/GameIdeas/Server/GameIdeas.WebAPI/Files/GameIdeas.fr.json +++ b/src/GameIdeas/Server/GameIdeas.WebAPI/Files/GameIdeas.fr.json @@ -45,5 +45,8 @@ "MaxStorageSpaceFormat": "Plus de {0}", "MinMaxStorageSpaceFormat": "{0} à {1}", "UserArgumentsNull": "Nom d'utilisateur ou mot de passe invalide", - "InvalidToken": "Le token JWT est invalide" + "InvalidToken": "Le token JWT est invalide", + "UserUnauthorized": "Utilisateur non authorisé", + "UserLoginFailed": "Authentification de l'utilisateur échoué", + "UserLogoutFailed": "Déconnection de l'utilisateur échoué" } \ No newline at end of file diff --git a/src/GameIdeas/Server/GameIdeas.WebAPI/Services/Users/IUserService.cs b/src/GameIdeas/Server/GameIdeas.WebAPI/Services/Users/IUserService.cs new file mode 100644 index 0000000..484ece9 --- /dev/null +++ b/src/GameIdeas/Server/GameIdeas.WebAPI/Services/Users/IUserService.cs @@ -0,0 +1,8 @@ +using GameIdeas.Shared.Dto; + +namespace GameIdeas.WebAPI.Services.Users; + +public interface IUserService +{ + Task Login(UserDto user); +} diff --git a/src/GameIdeas/Server/GameIdeas.WebAPI/Services/Users/UserService.cs b/src/GameIdeas/Server/GameIdeas.WebAPI/Services/Users/UserService.cs new file mode 100644 index 0000000..03dc42d --- /dev/null +++ b/src/GameIdeas/Server/GameIdeas.WebAPI/Services/Users/UserService.cs @@ -0,0 +1,56 @@ +using GameIdeas.Resources; +using GameIdeas.Shared.Constants; +using GameIdeas.Shared.Dto; +using GameIdeas.Shared.Model; +using GameIdeas.WebAPI.Exceptions; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace GameIdeas.WebAPI.Services.Users; + +public class UserService(UserManager userManager) : IUserService +{ + public async Task Login(UserDto userDto) + { + if (userDto.Username == null || userDto.Password == null) + throw new UserInvalidException(ResourcesKey.UserArgumentsNull); + + var user = await userManager.FindByNameAsync(userDto.Username); + + if (user == null || !await userManager.CheckPasswordAsync(user, userDto.Password)) + { + throw new UserUnauthorizedException(ResourcesKey.UserUnauthorized); + } + + List authClaims = + [ + new Claim(ClaimTypes.Name, user.UserName ?? string.Empty), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + ]; + + authClaims.AddRange((await userManager.GetRolesAsync(user)) + .Select(r => new Claim(ClaimTypes.Role, r))); + + var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY") + ?? throw new ArgumentNullException(message: ResourcesKey.InvalidToken, null); + + var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); + + var token = new JwtSecurityToken( + issuer: Environment.GetEnvironmentVariable("JWT_ISSUER"), + audience: Environment.GetEnvironmentVariable("JWT_AUDIENCE"), + expires: DateTime.Now.AddHours(GlobalConstants.JWT_DURATION_HOUR), + claims: authClaims, + signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) + ); + + return new TokenDto + { + Token = new JwtSecurityTokenHandler().WriteToken(token), + Expiration = token.ValidTo + }; + } +}