Add Authentication on frontend
This commit is contained in:
@@ -9,10 +9,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Blazored.FluentValidation" Version="2.2.0" />
|
<PackageReference Include="Blazored.FluentValidation" Version="2.2.0" />
|
||||||
|
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
|
||||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="9.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.2" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.2" PrivateAssets="all" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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<bool> Login(UserDto userDto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = await httpClient.PostAsync<TokenDto>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using GameIdeas.Shared.Dto;
|
||||||
|
|
||||||
|
namespace GameIdeas.BlazorApp.Pages.User.Gateways;
|
||||||
|
|
||||||
|
public interface IAuthGateway
|
||||||
|
{
|
||||||
|
Task<bool> Login(UserDto userDto);
|
||||||
|
Task Logout();
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using Blazored.LocalStorage;
|
||||||
using GameIdeas.BlazorApp;
|
using GameIdeas.BlazorApp;
|
||||||
using GameIdeas.BlazorApp.Pages.Games.Gateways;
|
using GameIdeas.BlazorApp.Pages.Games.Gateways;
|
||||||
|
using GameIdeas.BlazorApp.Pages.User.Gateways;
|
||||||
using GameIdeas.BlazorApp.Services;
|
using GameIdeas.BlazorApp.Services;
|
||||||
using GameIdeas.Resources;
|
using GameIdeas.Resources;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
var services = builder.Services;
|
||||||
|
|
||||||
builder.RootComponents.Add<App>("#app");
|
builder.RootComponents.Add<App>("#app");
|
||||||
builder.RootComponents.Add<HeadOutlet>("head::after");
|
builder.RootComponents.Add<HeadOutlet>("head::after");
|
||||||
|
|
||||||
@@ -15,7 +19,7 @@ UriBuilder uriBuilder = new(builder.HostEnvironment.BaseAddress)
|
|||||||
Port = 8000
|
Port = 8000
|
||||||
};
|
};
|
||||||
|
|
||||||
builder.Services.AddHttpClient(
|
services.AddHttpClient(
|
||||||
"GameIdeas.WebAPI",
|
"GameIdeas.WebAPI",
|
||||||
client =>
|
client =>
|
||||||
{
|
{
|
||||||
@@ -23,11 +27,18 @@ builder.Services.AddHttpClient(
|
|||||||
client.Timeout = TimeSpan.FromMinutes(3);
|
client.Timeout = TimeSpan.FromMinutes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddScoped<IHttpClientService, HttpClientService>();
|
services.AddBlazoredLocalStorage();
|
||||||
builder.Services.AddScoped<IGameGateway, GameGateway>();
|
services.AddAuthorizationCore();
|
||||||
|
|
||||||
builder.Services.AddSingleton<TranslationService>();
|
services.AddScoped<JwtAuthenticationStateProvider>();
|
||||||
builder.Services.AddSingleton<Translations>();
|
|
||||||
|
services.AddScoped<IHttpClientService, HttpClientService>();
|
||||||
|
|
||||||
|
services.AddScoped<IAuthGateway, AuthGateway>();
|
||||||
|
services.AddScoped<IGameGateway, GameGateway>();
|
||||||
|
|
||||||
|
services.AddSingleton<TranslationService>();
|
||||||
|
services.AddSingleton<Translations>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -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<AuthenticationState> 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,4 +15,9 @@ public static class Endpoints
|
|||||||
{
|
{
|
||||||
public static readonly string AllCategories = "api/Category/All";
|
public static readonly string AllCategories = "api/Category/All";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class Auth
|
||||||
|
{
|
||||||
|
public static readonly string Login = "api/User/Login";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace GameIdeas.BlazorApp.Shared.Exceptions;
|
||||||
|
|
||||||
|
public class AuthenticationUserException(string message) : Exception(message);
|
||||||
@@ -50,6 +50,9 @@ public class Translations (TranslationService translationService)
|
|||||||
public string MinMaxStorageSpaceFormat => translationService.Translate(nameof(MinMaxStorageSpaceFormat));
|
public string MinMaxStorageSpaceFormat => translationService.Translate(nameof(MinMaxStorageSpaceFormat));
|
||||||
public string UserArgumentsNull => translationService.Translate(nameof(UserArgumentsNull));
|
public string UserArgumentsNull => translationService.Translate(nameof(UserArgumentsNull));
|
||||||
public string InvalidToken => translationService.Translate(nameof(InvalidToken));
|
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
|
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 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 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 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.");
|
||||||
}
|
}
|
||||||
@@ -1,54 +1,37 @@
|
|||||||
using GameIdeas.Resources;
|
using GameIdeas.Shared.Dto;
|
||||||
using GameIdeas.Shared.Constants;
|
using GameIdeas.WebAPI.Exceptions;
|
||||||
using GameIdeas.Shared.Dto;
|
using GameIdeas.WebAPI.Services.Users;
|
||||||
using GameIdeas.Shared.Model;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace GameIdeas.WebAPI.Controllers;
|
namespace GameIdeas.WebAPI.Controllers;
|
||||||
|
|
||||||
public class UserController(UserManager<User> userManager) : Controller
|
public class UserController(
|
||||||
|
IUserService userService,
|
||||||
|
ILoggerFactory loggerFactory) : Controller
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<UserController> logger = loggerFactory.CreateLogger<UserController>();
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult<TokenDto>> Login([FromBody] UserDto model)
|
public async Task<ActionResult<TokenDto>> Login([FromBody] UserDto model)
|
||||||
{
|
{
|
||||||
if (model.Username == null || model.Password == null)
|
try
|
||||||
throw new ArgumentNullException(paramName: nameof(model), ResourcesKey.UserArgumentsNull);
|
{
|
||||||
|
return Ok(await userService.Login(model));
|
||||||
var user = await userManager.FindByNameAsync(model.Username);
|
}
|
||||||
|
catch (UserInvalidException e)
|
||||||
if (user != null && await userManager.CheckPasswordAsync(user, model.Password))
|
{
|
||||||
{
|
logger.LogInformation(e, "Missing informations for authentication");
|
||||||
List<Claim> authClaims =
|
return StatusCode(406, e.Message);
|
||||||
[
|
}
|
||||||
new Claim(ClaimTypes.Name, user.UserName ?? string.Empty),
|
catch (UserUnauthorizedException e)
|
||||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
{
|
||||||
];
|
logger.LogWarning(e, "Authentication invalid with there informations");
|
||||||
|
return Unauthorized(e.Message);
|
||||||
var jwtKey = Environment.GetEnvironmentVariable("JWT_KEY")
|
}
|
||||||
?? throw new ArgumentNullException(message: ResourcesKey.InvalidToken, null);
|
catch (Exception e)
|
||||||
|
{
|
||||||
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
|
logger.LogError(e, "Internal error while search games");
|
||||||
|
return StatusCode(500, e.Message);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Unauthorized();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace GameIdeas.WebAPI.Exceptions;
|
||||||
|
|
||||||
|
public class UserInvalidException (string message) : Exception(message);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace GameIdeas.WebAPI.Exceptions;
|
||||||
|
|
||||||
|
public class UserUnauthorizedException(string message) : Exception(message);
|
||||||
@@ -45,5 +45,8 @@
|
|||||||
"MaxStorageSpaceFormat": "Plus de {0}",
|
"MaxStorageSpaceFormat": "Plus de {0}",
|
||||||
"MinMaxStorageSpaceFormat": "{0} à {1}",
|
"MinMaxStorageSpaceFormat": "{0} à {1}",
|
||||||
"UserArgumentsNull": "Nom d'utilisateur ou mot de passe invalide",
|
"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é"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using GameIdeas.Shared.Dto;
|
||||||
|
|
||||||
|
namespace GameIdeas.WebAPI.Services.Users;
|
||||||
|
|
||||||
|
public interface IUserService
|
||||||
|
{
|
||||||
|
Task<TokenDto> Login(UserDto user);
|
||||||
|
}
|
||||||
@@ -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<User> userManager) : IUserService
|
||||||
|
{
|
||||||
|
public async Task<TokenDto> 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<Claim> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user