Introduction
Authorization is a critical aspect of building secure applications. In ASP.NET Core, the built-in authorization framework can be extended to support fine-grained permission-based control. This article explains how to implement a permission-based authorization system using custom attributes, policies, and handlers.
Overview of Permission-Based Authorization
Permissions provide a more granular approach to controlling user access compared to role-based authorization. By associating specific actions with permissions, developers can enforce precise rules for accessing resources.
Key Components of the System
1. PermissionsEnum
The PermissionsEnum defines all the permissions in the system. Each permission is assigned a unique value:
public enum PermissionsEnum
{
// Users
UserRead = 1,
UserCreate = 2,
UserModify = 3,
UserDelete = 4,
// Roles
RoleRead = 5,
RoleCreate = 6,
RoleModify = 7,
RoleDelete = 8
}
2. Controller with Permission Attribute
Permissions are applied to controller actions using the HasPermission attribute. For example:
[HttpGet("users")]
[HasPermission(PermissionsEnum.UserRead)]
public async Task<ActionResult<PaginatedResponse<GetUserDTO>>> GetAllUsers(int pageNumber = 1, int pageSize = 10, string search = null)
{
var result = await _user.GetUsersAsync(pageNumber, pageSize, search);
return Ok(result);
}
The HasPermission attribute ensures that users must have at least one of the specified permissions to access this endpoint. ie.
[HasPermission(PermissionsEnum.UserRead, PermissionsEnum.UserRead)]
3. Custom Attribute: HasPermission
The HasPermission attribute simplifies permission checks by associating multiple permissions with a policy:
using Domain.Enums; // specify directory for permissions enums
using Microsoft.AspNetCore.Authorization;
public sealed class HasPermissionAttribute : AuthorizeAttribute
{
public HasPermissionAttribute(params PermissionsEnum[] permissions)
: base(policy: string.Join(",", permissions.Select(p => p.ToString())))
{
}
}
4. Policy Provider
The PermissionAuthorizationPolicyProvider dynamically generates authorization policies based on permission names:
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
public class PermissionAuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider
{
public PermissionAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
: base(options) { }
public override async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
var policy = await base.GetPolicyAsync(policyName);
if (policy is not null)
return policy;
var permissions = policyName.Split(',');
return new AuthorizationPolicyBuilder()
.AddRequirements(new PermissionRequirement(permissions))
.Build();
}
}
5. Authorization Handler
The PermissionAuthorizationHandler evaluates if the user has any of the required permissions:
using Microsoft.AspNetCore.Authorization;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement)
{
var userPermissions = context.User.Claims
.Where(c => c.Type == "permission")
.Select(c => c.Value);
if (requirement.Permissions.Any(permission => userPermissions.Contains(permission)))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Integration in ASP.NET Core
Service Configuration
Register the required services in Program.cs:
services.AddAuthorization();
services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
services.AddSingleton<IAuthorizationPolicyProvider, PermissionAuthorizationPolicyProvider>();
Adding Permissions to Users
Permissions should be added as claims to user identities during login or token generation:
public async Task<LoginResponse> LoginUserAsync(LoginDTO loginDTO)
{
var getUser = await FindUserByEmail(loginDTO.Email!);
if (getUser == null)
return new LoginResponse(false, "Invalid credentials!");
bool checkPassword = BCrypt.Net.BCrypt.Verify(loginDTO.Password, getUser.Password);
if (checkPassword)
{
if (getUser.EmailVerifiedOn is null)
return new LoginResponse(false, "Your email is not verified");
var accessToken = await _authTokenService.GenerateToken(getUser);
var refreshToken = _authTokenService.GenerateRefreshToken();
_authTokenService.SetRefreshTokenAsHttpOnlyCookie(refreshToken);
// Update database
getUser.RefreshToken = refreshToken.TokenValue;
getUser.RefreshTokenExpiryTime = refreshToken.Expires;
await _appDbContext.SaveChangesAsync();
return new LoginResponse(true, "Login successful", accessToken);
}
else
return new LoginResponse(false, "Invalid credentials!");
}
public async Task<string> GenerateToken(User user)
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]!));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var userClaims = new List<Claim>()
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Name, user.Name),
new Claim(JwtRegisteredClaimNames.Email, user.Email)
};
foreach (var permission in await _user.GetUserPermissions(user))
userClaims.Add(new Claim("permission", permission.Name));
var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Audience"],
claims: userClaims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public RefreshToken GenerateRefreshToken()
{
var refreshToken = new RefreshToken
{
TokenValue = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)),
Expires = DateTime.UtcNow.AddDays(7)
};
return refreshToken;
}
public void SetRefreshTokenAsHttpOnlyCookie(RefreshToken refreshToken)
{
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.None,
Expires = refreshToken.Expires
};
var httpContext = _httpContextAccessor.HttpContext;
httpContext?.Response.Cookies.Append(_configuration["Jwt:RefreshTokenCookieKey"], refreshToken.TokenValue, cookieOptions);
}
Database Seeder
To get started, you may need to seed permissions into your database. Additionally, it’s a good practice to seed an Admin role and user when running your application for the first time. Here's how you can do it:
public static class DatabaseSeeder
{
public static async void SeedAsync(IServiceProvider serviceProvider)
{
using var scope = serviceProvider.CreateScope();
var _context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Ensure the database is migrated
await _context.Database.MigrateAsync();
// 1. Seed Permissions
var permissions = Enum.GetValues(typeof(PermissionsEnum))
.Cast<PermissionsEnum>()
.Select(e => new Permission { Name = e.ToString() })
.ToList(); // Get permissions from Enum File
var permissionToAdd = new List<Permission>();
foreach (var permission in permissions)
{
if (!_context.Permissions.Any(p => p.Name == permission.Name))
{
permissionToAdd.Add(new Permission
{
Name = permission.Name,
});
}
}
await _context.AddRangeAsync(permissionToAdd);
await _context.SaveChangesAsync();
// 2.Seed Admin Role
var adminRole = new Role { Name = "Admin" };
if (!_context.Roles.Any(r => r.Name == adminRole.Name))
{
_context.Roles.Add(adminRole);
await _context.SaveChangesAsync();
}
else
{
adminRole = _context.Roles.FirstOrDefault(r => r.Name == adminRole.Name);
}
// 3. Assign all permissions to Admin role
var storedPermissions = await _context.Permissions.ToListAsync();
foreach (var permission in storedPermissions)
{
if (!_context.RolePermissions.Any(rp => rp.RoleId == adminRole.Id && rp.PermissionId == permission.Id))
{
_context.RolePermissions.Add(new RolePermission
{
RoleId = adminRole.Id,
PermissionId = permission.Id
});
}
}
await _context.SaveChangesAsync();
// 4. Seed Admin user
var adminUser = new User
{
Name = "Alex Mutegi",
Email = "username@example.com",
Password = BCrypt.Net.BCrypt.HashPassword("MyStrongPassword"),
EmailVerifiedOn = DateTime.UtcNow
};
if (!_context.Users.Any(u => u.Email == adminUser.Email))
{
_context.Users.Add(adminUser);
await _context.SaveChangesAsync();
}
else
{
adminUser = _context.Users.FirstOrDefault(u => u.Email == adminUser.Email);
}
// 5. Assign Admin Role to Admin User
if (!_context.UserRoles.Any(ur => ur.UserId == adminUser.Id && ur.RoleId == adminRole.Id))
{
_context.UserRoles.Add(new UserRole
{
UserId = adminUser.Id,
RoleId = adminRole.Id
});
}
await _context.SaveChangesAsync();
}
}
On the Program.cs, register the seeder like below:
var app = builder.Build();
DatabaseSeeder.SeedAsync(app.Services);
Database diagram might look like the below:
Conclusion
With this setup, your ASP.NET Core application now supports a robust permission-based authorization system. By leveraging the flexibility of custom attributes, policy providers, and handlers, you can enforce fine-grained control over your application’s resources.
Top comments (0)