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