Add JWT authentication and authorization infrastructure

This commit is contained in:
2026-08-18 15:43:46 +05:30
parent ea2e5229c8
commit e0e691acbf
18 changed files with 395 additions and 4 deletions
+3 -1
View File
@@ -7,13 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
</ItemGroup>
</Project>
@@ -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<IActionResult> Login(LoginRequest request)
{
var result = await service.LoginAsync(request);
return result is null ? Unauthorized() : Ok(result);
}
}
+5
View File
@@ -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);
+24
View File
@@ -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<Models.User> _hasher = new();
public async Task<LoginResponse?> 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);
}
}
@@ -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;
}
}
}
}
@@ -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; }
}
}
@@ -0,0 +1,7 @@
namespace Distribution.Infra.Auth
{
public interface IJwtTokenService
{
string GenerateToken(Models.User user);
}
}
@@ -0,0 +1,7 @@
namespace Distribution.Infra.Auth
{
public interface IPermissionService
{
Task<bool> HasPermissionAsync(Guid roleId, string permissionCode);
}
}
@@ -0,0 +1,10 @@
namespace Distribution.Infra.Auth
{
public interface IScopeAccessService
{
Task<bool> HasStoreScopeAsync(Guid userId, Guid storeId);
Task<bool> HasRouteScopeAsync(Guid userId, Guid routeId);
Task<bool> HasRegionScopeAsync(Guid userId, Guid regionId);
Task<List<Guid>> GetAccessibleStoreIdsAsync(Guid userId);
}
}
@@ -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<Claim>
{
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);
}
}
}
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
namespace Distribution.Infra.Auth
{
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
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);
}
}
}
@@ -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<AuthorizationOptions> options)
{
_fallback = new DefaultAuthorizationPolicyProvider(options);
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.Contains('.'))
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(policyName))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
return _fallback.GetPolicyAsync(policyName);
}
}
}
@@ -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;
}
}
}
@@ -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<bool> 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);
}
}
@@ -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<bool> HasStoreScopeAsync(Guid userId, Guid storeId) =>
await _context.UserStoreScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_StoreId == storeId && s.IsActive);
public async Task<bool> HasRouteScopeAsync(Guid userId, Guid routeId) =>
await _context.UserRouteScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_RouteId == routeId && s.IsActive);
public async Task<bool> HasRegionScopeAsync(Guid userId, Guid regionId) =>
await _context.UserRegionScopes.AnyAsync(s => s.FK_UserId == userId && s.FK_RegionId == regionId && s.IsActive);
public async Task<List<Guid>> 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();
}
}
}
+51
View File
@@ -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<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
@@ -32,18 +42,59 @@ builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<RoleService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IJwtTokenService, JwtTokenService>();
builder.Services.AddScoped<IPermissionService, PermissionService>();
builder.Services.AddScoped<IScopeAccessService, ScopeAccessService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
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();
+4 -2
View File
@@ -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"
+7 -1
View File
@@ -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
}
}