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
+ };
+ }
+}