diff --git a/Distribution/Distribution.csproj b/Distribution/Distribution.csproj index dcc0bb9..36d2f5c 100644 --- a/Distribution/Distribution.csproj +++ b/Distribution/Distribution.csproj @@ -7,13 +7,15 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all - + + diff --git a/Distribution/Features/Auth/AuthController.cs b/Distribution/Features/Auth/AuthController.cs new file mode 100644 index 0000000..1209f46 --- /dev/null +++ b/Distribution/Features/Auth/AuthController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.Auth; + +[ApiController] +[Route("api/auth")] +public class AuthController(AuthService service) : ControllerBase +{ + [HttpPost("login")] + public async Task Login(LoginRequest request) + { + var result = await service.LoginAsync(request); + return result is null ? Unauthorized() : Ok(result); + } +} diff --git a/Distribution/Features/Auth/AuthDTOs.cs b/Distribution/Features/Auth/AuthDTOs.cs new file mode 100644 index 0000000..bb7d71f --- /dev/null +++ b/Distribution/Features/Auth/AuthDTOs.cs @@ -0,0 +1,5 @@ +namespace Distribution.Features.Auth; + +public record LoginRequest(string Username, string Password); + +public record LoginResponse(string Token, Guid UserId, string Username, string RoleName); diff --git a/Distribution/Features/Auth/AuthService.cs b/Distribution/Features/Auth/AuthService.cs new file mode 100644 index 0000000..6aa963d --- /dev/null +++ b/Distribution/Features/Auth/AuthService.cs @@ -0,0 +1,24 @@ +using Distribution.Features.User; +using Distribution.Infra.Auth; +using Microsoft.AspNetCore.Identity; + +namespace Distribution.Features.Auth; + +public class AuthService(IUserRepository userRepo, IJwtTokenService tokenService) +{ + private readonly PasswordHasher _hasher = new(); + + public async Task LoginAsync(LoginRequest request) + { + var user = await userRepo.GetByUsernameAsync(request.Username); + if (user is null || !user.IsActive) + return null; + + var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); + if (result == PasswordVerificationResult.Failed) + return null; + + var token = tokenService.GenerateToken(user); + return new LoginResponse(token, user.Id, user.Username, user.Role?.Name ?? string.Empty); + } +} diff --git a/Distribution/Infra/Auth/CurrentUserService.cs b/Distribution/Infra/Auth/CurrentUserService.cs new file mode 100644 index 0000000..34271fe --- /dev/null +++ b/Distribution/Infra/Auth/CurrentUserService.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; + +namespace Distribution.Infra.Auth +{ + public class CurrentUserService : ICurrentUserService + { + private readonly IHttpContextAccessor _httpContextAccessor; + + public CurrentUserService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + private System.Security.Claims.ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User; + + public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false; + + public Guid? UserId + { + get + { + var value = User?.FindFirst("sub")?.Value ?? User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return Guid.TryParse(value, out var id) ? id : null; + } + } + + public Guid? RoleId + { + get + { + var value = User?.FindFirst("role_id")?.Value; + return Guid.TryParse(value, out var id) ? id : null; + } + } + + public string? Username => User?.FindFirst("username")?.Value; + + public int RoleLevel + { + get + { + var value = User?.FindFirst("role_level")?.Value; + return int.TryParse(value, out var level) ? level : 0; + } + } + } +} diff --git a/Distribution/Infra/Auth/ICurrentUserService.cs b/Distribution/Infra/Auth/ICurrentUserService.cs new file mode 100644 index 0000000..69224e9 --- /dev/null +++ b/Distribution/Infra/Auth/ICurrentUserService.cs @@ -0,0 +1,11 @@ +namespace Distribution.Infra.Auth +{ + public interface ICurrentUserService + { + Guid? UserId { get; } + Guid? RoleId { get; } + string? Username { get; } + int RoleLevel { get; } + bool IsAuthenticated { get; } + } +} diff --git a/Distribution/Infra/Auth/IJwtTokenService.cs b/Distribution/Infra/Auth/IJwtTokenService.cs new file mode 100644 index 0000000..b95986e --- /dev/null +++ b/Distribution/Infra/Auth/IJwtTokenService.cs @@ -0,0 +1,7 @@ +namespace Distribution.Infra.Auth +{ + public interface IJwtTokenService + { + string GenerateToken(Models.User user); + } +} diff --git a/Distribution/Infra/Auth/IPermissionService.cs b/Distribution/Infra/Auth/IPermissionService.cs new file mode 100644 index 0000000..e9b0e13 --- /dev/null +++ b/Distribution/Infra/Auth/IPermissionService.cs @@ -0,0 +1,7 @@ +namespace Distribution.Infra.Auth +{ + public interface IPermissionService + { + Task HasPermissionAsync(Guid roleId, string permissionCode); + } +} diff --git a/Distribution/Infra/Auth/IScopeAccessService.cs b/Distribution/Infra/Auth/IScopeAccessService.cs new file mode 100644 index 0000000..c6a4950 --- /dev/null +++ b/Distribution/Infra/Auth/IScopeAccessService.cs @@ -0,0 +1,10 @@ +namespace Distribution.Infra.Auth +{ + public interface IScopeAccessService + { + Task HasStoreScopeAsync(Guid userId, Guid storeId); + Task HasRouteScopeAsync(Guid userId, Guid routeId); + Task HasRegionScopeAsync(Guid userId, Guid regionId); + Task> GetAccessibleStoreIdsAsync(Guid userId); + } +} diff --git a/Distribution/Infra/Auth/JwtTokenService.cs b/Distribution/Infra/Auth/JwtTokenService.cs new file mode 100644 index 0000000..c0d4fa5 --- /dev/null +++ b/Distribution/Infra/Auth/JwtTokenService.cs @@ -0,0 +1,48 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Distribution.Infra.Auth +{ + public class JwtTokenService : IJwtTokenService + { + private readonly IConfiguration _configuration; + + public JwtTokenService(IConfiguration configuration) + { + _configuration = configuration; + } + + public string GenerateToken(Models.User user) + { + var jwtSection = _configuration.GetSection("Jwt"); + var key = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); + var issuer = jwtSection["Issuer"]; + var audience = jwtSection["Audience"]; + var expiryMinutes = int.TryParse(jwtSection["ExpiryMinutes"], out var m) ? m : 120; + + var claims = new List + { + new("sub", user.Id.ToString()), + new("username", user.Username), + new("role", user.Role?.Name ?? string.Empty), + new("role_id", user.FK_RoleId.ToString()), + new("role_level", (user.Role?.Level ?? 0).ToString()) + }; + + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)); + var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.Now.AddMinutes(expiryMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + } +} diff --git a/Distribution/Infra/Auth/PermissionAuthorizationHandler.cs b/Distribution/Infra/Auth/PermissionAuthorizationHandler.cs new file mode 100644 index 0000000..0582e85 --- /dev/null +++ b/Distribution/Infra/Auth/PermissionAuthorizationHandler.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Distribution.Infra.Auth +{ + public class PermissionAuthorizationHandler : AuthorizationHandler + { + private readonly IPermissionService _permissionService; + + public PermissionAuthorizationHandler(IPermissionService permissionService) + { + _permissionService = permissionService; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, PermissionRequirement requirement) + { + var roleIdClaim = context.User.FindFirst("role_id")?.Value; + if (!Guid.TryParse(roleIdClaim, out var roleId)) + return; + + if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode)) + context.Succeed(requirement); + } + } +} diff --git a/Distribution/Infra/Auth/PermissionPolicyProvider.cs b/Distribution/Infra/Auth/PermissionPolicyProvider.cs new file mode 100644 index 0000000..994d87a --- /dev/null +++ b/Distribution/Infra/Auth/PermissionPolicyProvider.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace Distribution.Infra.Auth +{ + public class PermissionPolicyProvider : IAuthorizationPolicyProvider + { + private readonly DefaultAuthorizationPolicyProvider _fallback; + + public PermissionPolicyProvider(IOptions options) + { + _fallback = new DefaultAuthorizationPolicyProvider(options); + } + + public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync(); + + public Task GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync(); + + public Task GetPolicyAsync(string policyName) + { + if (policyName.Contains('.')) + { + var policy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddRequirements(new PermissionRequirement(policyName)) + .Build(); + return Task.FromResult(policy); + } + + return _fallback.GetPolicyAsync(policyName); + } + } +} diff --git a/Distribution/Infra/Auth/PermissionRequirement.cs b/Distribution/Infra/Auth/PermissionRequirement.cs new file mode 100644 index 0000000..7ac9421 --- /dev/null +++ b/Distribution/Infra/Auth/PermissionRequirement.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Distribution.Infra.Auth +{ + public class PermissionRequirement : IAuthorizationRequirement + { + public string PermissionCode { get; } + + public PermissionRequirement(string permissionCode) + { + PermissionCode = permissionCode; + } + } +} diff --git a/Distribution/Infra/Auth/PermissionService.cs b/Distribution/Infra/Auth/PermissionService.cs new file mode 100644 index 0000000..234ae04 --- /dev/null +++ b/Distribution/Infra/Auth/PermissionService.cs @@ -0,0 +1,21 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Infra.Auth +{ + public class PermissionService : IPermissionService + { + private readonly AppDBContext _context; + + public PermissionService(AppDBContext context) + { + _context = context; + } + + public async Task HasPermissionAsync(Guid roleId, string permissionCode) => + await _context.RolePermissions + .Where(rp => rp.FK_RoleId == roleId && rp.IsActive) + .Join(_context.Permissions.Where(p => p.IsActive), rp => rp.FK_PermissionId, p => p.Id, (rp, p) => p.Code) + .AnyAsync(code => code == permissionCode); + } +} diff --git a/Distribution/Infra/Auth/ScopeAccessService.cs b/Distribution/Infra/Auth/ScopeAccessService.cs new file mode 100644 index 0000000..bc5887c --- /dev/null +++ b/Distribution/Infra/Auth/ScopeAccessService.cs @@ -0,0 +1,63 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Infra.Auth +{ + public class ScopeAccessService : IScopeAccessService + { + private readonly AppDBContext _context; + + public ScopeAccessService(AppDBContext context) + { + _context = context; + } + + public async Task HasStoreScopeAsync(Guid userId, Guid storeId) => + await _context.UserStoreScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_StoreId == storeId && s.IsActive); + + public async Task HasRouteScopeAsync(Guid userId, Guid routeId) => + await _context.UserRouteScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_RouteId == routeId && s.IsActive); + + public async Task HasRegionScopeAsync(Guid userId, Guid regionId) => + await _context.UserRegionScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_RegionId == regionId && s.IsActive); + + public async Task> GetAccessibleStoreIdsAsync(Guid userId) + { + var directStoreIds = await _context.UserStoreScopes + .Where(s => s.FK_UserId == userId && s.IsActive) + .Select(s => s.FK_StoreId) + .ToListAsync(); + + var routeIds = await _context.UserRouteScopes + .Where(s => s.FK_UserId == userId && s.IsActive) + .Select(s => s.FK_RouteId) + .ToListAsync(); + + var regionIds = await _context.UserRegionScopes + .Where(s => s.FK_UserId == userId && s.IsActive) + .Select(s => s.FK_RegionId) + .ToListAsync(); + + var storeIdsViaRoute = await _context.Stores + .Where(s => routeIds.Contains(s.FK_RouteId) && s.IsActive) + .Select(s => s.Id) + .ToListAsync(); + + var routeIdsViaRegion = await _context.Routes + .Where(r => regionIds.Contains(r.FK_RegionId) && r.IsActive) + .Select(r => r.Id) + .ToListAsync(); + + var storeIdsViaRegion = await _context.Stores + .Where(s => routeIdsViaRegion.Contains(s.FK_RouteId) && s.IsActive) + .Select(s => s.Id) + .ToListAsync(); + + return directStoreIds + .Concat(storeIdsViaRoute) + .Concat(storeIdsViaRegion) + .Distinct() + .ToList(); + } + } +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index 9348904..c89a5cc 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -1,12 +1,20 @@ +using System.Text; using Distribution.Data; +using Distribution.Features.Auth; using Distribution.Features.Order; using Distribution.Features.Region; +using Distribution.Features.Role; using Distribution.Features.Route; using Distribution.Features.Store; +using Distribution.Features.User; using Distribution.Features.Vehicle; using Distribution.Features.Warehouse; +using Distribution.Infra.Auth; using Distribution.Infra.UoW; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using MySql.EntityFrameworkCore.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -25,6 +33,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -32,18 +42,59 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +var jwtSection = builder.Configuration.GetSection("Jwt"); +var jwtKey = jwtSection["Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); + +builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtSection["Issuer"], + ValidateAudience = true, + ValidAudience = jwtSection["Audience"], + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }; + }); + +builder.Services.AddAuthorization(); builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); +builder.Services.AddSwaggerGen(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + app.UseSwagger(); + app.UseSwaggerUI(); } app.UseHttpsRedirection(); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/Distribution/Properties/launchSettings.json b/Distribution/Properties/launchSettings.json index 61ce476..29cfb73 100644 --- a/Distribution/Properties/launchSettings.json +++ b/Distribution/Properties/launchSettings.json @@ -4,7 +4,8 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, + "launchUrl": "swagger", "applicationUrl": "http://localhost:5089", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -13,7 +14,8 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, + "launchUrl": "swagger", "applicationUrl": "https://localhost:7025;http://localhost:5089", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/Distribution/appsettings.json b/Distribution/appsettings.json index d48934f..6b24767 100644 --- a/Distribution/appsettings.json +++ b/Distribution/appsettings.json @@ -8,5 +8,11 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Jwt": { + "Key": "CHANGE_ME_dev_only_replace_with_a_long_random_secret_before_deploying", + "Issuer": "Distribution.Api", + "Audience": "Distribution.Api", + "ExpiryMinutes": 120 + } }