├── .gitignore ├── Authorization ├── AllowAnonymousAttribute.cs ├── AuthorizeAttribute.cs ├── JwtMiddleware.cs └── JwtUtils.cs ├── Controllers └── UsersController.cs ├── Entities ├── Role.cs └── User.cs ├── Helpers ├── AppException.cs ├── AppSettings.cs ├── DataContext.cs └── ErrorHandlerMiddleware.cs ├── LICENSE ├── Models └── Users │ ├── AuthenticateRequest.cs │ └── AuthenticateResponse.cs ├── Program.cs ├── README.md ├── Services └── UserService.cs ├── WebApi.csproj └── appsettings.json /.gitignore: -------------------------------------------------------------------------------- 1 | # .NET compiled files 2 | bin 3 | obj 4 | -------------------------------------------------------------------------------- /Authorization/AllowAnonymousAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Authorization; 2 | 3 | [AttributeUsage(AttributeTargets.Method)] 4 | public class AllowAnonymousAttribute : Attribute 5 | { } -------------------------------------------------------------------------------- /Authorization/AuthorizeAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Authorization; 2 | 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.Filters; 5 | using WebApi.Entities; 6 | 7 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] 8 | public class AuthorizeAttribute : Attribute, IAuthorizationFilter 9 | { 10 | private readonly IList _roles; 11 | 12 | public AuthorizeAttribute(params Role[] roles) 13 | { 14 | _roles = roles ?? new Role[] { }; 15 | } 16 | 17 | public void OnAuthorization(AuthorizationFilterContext context) 18 | { 19 | // skip authorization if action is decorated with [AllowAnonymous] attribute 20 | var allowAnonymous = context.ActionDescriptor.EndpointMetadata.OfType().Any(); 21 | if (allowAnonymous) 22 | return; 23 | 24 | // authorization 25 | var user = (User)context.HttpContext.Items["User"]; 26 | if (user == null || (_roles.Any() && !_roles.Contains(user.Role))) 27 | { 28 | // not logged in or role not authorized 29 | context.Result = new JsonResult(new { message = "Unauthorized" }) { StatusCode = StatusCodes.Status401Unauthorized }; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /Authorization/JwtMiddleware.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Authorization; 2 | 3 | using Microsoft.Extensions.Options; 4 | using WebApi.Helpers; 5 | using WebApi.Services; 6 | 7 | public class JwtMiddleware 8 | { 9 | private readonly RequestDelegate _next; 10 | private readonly AppSettings _appSettings; 11 | 12 | public JwtMiddleware(RequestDelegate next, IOptions appSettings) 13 | { 14 | _next = next; 15 | _appSettings = appSettings.Value; 16 | } 17 | 18 | public async Task Invoke(HttpContext context, IUserService userService, IJwtUtils jwtUtils) 19 | { 20 | var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last(); 21 | var userId = jwtUtils.ValidateJwtToken(token); 22 | if (userId != null) 23 | { 24 | // attach user to context on successful jwt validation 25 | context.Items["User"] = userService.GetById(userId.Value); 26 | } 27 | 28 | await _next(context); 29 | } 30 | } -------------------------------------------------------------------------------- /Authorization/JwtUtils.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Authorization; 2 | 3 | using Microsoft.Extensions.Options; 4 | using Microsoft.IdentityModel.Tokens; 5 | using System.IdentityModel.Tokens.Jwt; 6 | using System.Security.Claims; 7 | using System.Text; 8 | using WebApi.Entities; 9 | using WebApi.Helpers; 10 | 11 | public interface IJwtUtils 12 | { 13 | public string GenerateJwtToken(User user); 14 | public int? ValidateJwtToken(string token); 15 | } 16 | 17 | public class JwtUtils : IJwtUtils 18 | { 19 | private readonly AppSettings _appSettings; 20 | 21 | public JwtUtils(IOptions appSettings) 22 | { 23 | _appSettings = appSettings.Value; 24 | } 25 | 26 | public string GenerateJwtToken(User user) 27 | { 28 | // generate token that is valid for 7 days 29 | var tokenHandler = new JwtSecurityTokenHandler(); 30 | var key = Encoding.ASCII.GetBytes(_appSettings.Secret); 31 | var tokenDescriptor = new SecurityTokenDescriptor 32 | { 33 | Subject = new ClaimsIdentity(new[] { new Claim("id", user.Id.ToString()) }), 34 | Expires = DateTime.UtcNow.AddDays(7), 35 | SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) 36 | }; 37 | var token = tokenHandler.CreateToken(tokenDescriptor); 38 | return tokenHandler.WriteToken(token); 39 | } 40 | 41 | public int? ValidateJwtToken(string token) 42 | { 43 | if (token == null) 44 | return null; 45 | 46 | var tokenHandler = new JwtSecurityTokenHandler(); 47 | var key = Encoding.ASCII.GetBytes(_appSettings.Secret); 48 | try 49 | { 50 | tokenHandler.ValidateToken(token, new TokenValidationParameters 51 | { 52 | ValidateIssuerSigningKey = true, 53 | IssuerSigningKey = new SymmetricSecurityKey(key), 54 | ValidateIssuer = false, 55 | ValidateAudience = false, 56 | // set clockskew to zero so tokens expire exactly at token expiration time (instead of 5 minutes later) 57 | ClockSkew = TimeSpan.Zero 58 | }, out SecurityToken validatedToken); 59 | 60 | var jwtToken = (JwtSecurityToken)validatedToken; 61 | var userId = int.Parse(jwtToken.Claims.First(x => x.Type == "id").Value); 62 | 63 | // return user id from JWT token if validation successful 64 | return userId; 65 | } 66 | catch 67 | { 68 | // return null if validation fails 69 | return null; 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /Controllers/UsersController.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Controllers; 2 | 3 | using Microsoft.AspNetCore.Mvc; 4 | using WebApi.Authorization; 5 | using WebApi.Entities; 6 | using WebApi.Models.Users; 7 | using WebApi.Services; 8 | 9 | [Authorize] 10 | [ApiController] 11 | [Route("[controller]")] 12 | public class UsersController : ControllerBase 13 | { 14 | private IUserService _userService; 15 | 16 | public UsersController(IUserService userService) 17 | { 18 | _userService = userService; 19 | } 20 | 21 | [AllowAnonymous] 22 | [HttpPost("[action]")] 23 | public IActionResult Authenticate(AuthenticateRequest model) 24 | { 25 | var response = _userService.Authenticate(model); 26 | return Ok(response); 27 | } 28 | 29 | [Authorize(Role.Admin)] 30 | [HttpGet] 31 | public IActionResult GetAll() 32 | { 33 | var users = _userService.GetAll(); 34 | return Ok(users); 35 | } 36 | 37 | [HttpGet("{id:int}")] 38 | public IActionResult GetById(int id) 39 | { 40 | // only admins can access other user records 41 | var currentUser = (User)HttpContext.Items["User"]; 42 | if (id != currentUser.Id && currentUser.Role != Role.Admin) 43 | return Unauthorized(new { message = "Unauthorized" }); 44 | 45 | var user = _userService.GetById(id); 46 | return Ok(user); 47 | } 48 | } -------------------------------------------------------------------------------- /Entities/Role.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Entities; 2 | 3 | public enum Role 4 | { 5 | Admin, 6 | User 7 | } -------------------------------------------------------------------------------- /Entities/User.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Entities; 2 | 3 | using System.Text.Json.Serialization; 4 | 5 | public class User 6 | { 7 | public int Id { get; set; } 8 | public string FirstName { get; set; } 9 | public string LastName { get; set; } 10 | public string Username { get; set; } 11 | public Role Role { get; set; } 12 | 13 | [JsonIgnore] 14 | public string PasswordHash { get; set; } 15 | } -------------------------------------------------------------------------------- /Helpers/AppException.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | using System.Globalization; 4 | 5 | // custom exception class for throwing application specific exceptions (e.g. for validation) 6 | // that can be caught and handled within the application 7 | public class AppException : Exception 8 | { 9 | public AppException() : base() {} 10 | 11 | public AppException(string message) : base(message) { } 12 | 13 | public AppException(string message, params object[] args) 14 | : base(String.Format(CultureInfo.CurrentCulture, message, args)) 15 | { 16 | } 17 | } -------------------------------------------------------------------------------- /Helpers/AppSettings.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | public class AppSettings 4 | { 5 | public string Secret { get; set; } 6 | } -------------------------------------------------------------------------------- /Helpers/DataContext.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | using Microsoft.EntityFrameworkCore; 4 | using WebApi.Entities; 5 | 6 | public class DataContext : DbContext 7 | { 8 | public DbSet Users { get; set; } 9 | 10 | private readonly IConfiguration Configuration; 11 | 12 | public DataContext(IConfiguration configuration) 13 | { 14 | Configuration = configuration; 15 | } 16 | 17 | protected override void OnConfiguring(DbContextOptionsBuilder options) 18 | { 19 | // in memory database used for simplicity, change to a real db for production applications 20 | options.UseInMemoryDatabase("TestDb"); 21 | } 22 | } -------------------------------------------------------------------------------- /Helpers/ErrorHandlerMiddleware.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Helpers; 2 | 3 | using Microsoft.AspNetCore.Http; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Net; 7 | using System.Text.Json; 8 | using System.Threading.Tasks; 9 | 10 | public class ErrorHandlerMiddleware 11 | { 12 | private readonly RequestDelegate _next; 13 | 14 | public ErrorHandlerMiddleware(RequestDelegate next) 15 | { 16 | _next = next; 17 | } 18 | 19 | public async Task Invoke(HttpContext context) 20 | { 21 | try 22 | { 23 | await _next(context); 24 | } 25 | catch (Exception error) 26 | { 27 | var response = context.Response; 28 | response.ContentType = "application/json"; 29 | 30 | switch(error) 31 | { 32 | case AppException e: 33 | // custom application error 34 | response.StatusCode = (int)HttpStatusCode.BadRequest; 35 | break; 36 | case KeyNotFoundException e: 37 | // not found error 38 | response.StatusCode = (int)HttpStatusCode.NotFound; 39 | break; 40 | default: 41 | // unhandled error 42 | response.StatusCode = (int)HttpStatusCode.InternalServerError; 43 | break; 44 | } 45 | 46 | var result = JsonSerializer.Serialize(new { message = error?.Message }); 47 | await response.WriteAsync(result); 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Jason Watmore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Models/Users/AuthenticateRequest.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Users; 2 | 3 | using System.ComponentModel.DataAnnotations; 4 | 5 | public class AuthenticateRequest 6 | { 7 | [Required] 8 | public string Username { get; set; } 9 | 10 | [Required] 11 | public string Password { get; set; } 12 | } -------------------------------------------------------------------------------- /Models/Users/AuthenticateResponse.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Models.Users; 2 | 3 | using WebApi.Entities; 4 | 5 | public class AuthenticateResponse 6 | { 7 | public int Id { get; set; } 8 | public string FirstName { get; set; } 9 | public string LastName { get; set; } 10 | public string Username { get; set; } 11 | public Role Role { get; set; } 12 | public string Token { get; set; } 13 | 14 | public AuthenticateResponse(User user, string token) 15 | { 16 | Id = user.Id; 17 | FirstName = user.FirstName; 18 | LastName = user.LastName; 19 | Username = user.Username; 20 | Role = user.Role; 21 | Token = token; 22 | } 23 | } -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using BCryptNet = BCrypt.Net.BCrypt; 2 | using System.Text.Json.Serialization; 3 | using WebApi.Authorization; 4 | using WebApi.Entities; 5 | using WebApi.Helpers; 6 | using WebApi.Services; 7 | 8 | var builder = WebApplication.CreateBuilder(args); 9 | 10 | // add services to DI container 11 | { 12 | var services = builder.Services; 13 | var env = builder.Environment; 14 | 15 | services.AddDbContext(); 16 | services.AddCors(); 17 | services.AddControllers().AddJsonOptions(x => 18 | { 19 | // serialize enums as strings in api responses (e.g. Role) 20 | x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); 21 | }); 22 | 23 | // configure strongly typed settings object 24 | services.Configure(builder.Configuration.GetSection("AppSettings")); 25 | 26 | // configure DI for application services 27 | services.AddScoped(); 28 | services.AddScoped(); 29 | } 30 | 31 | var app = builder.Build(); 32 | 33 | // configure HTTP request pipeline 34 | { 35 | // global cors policy 36 | app.UseCors(x => x 37 | .AllowAnyOrigin() 38 | .AllowAnyMethod() 39 | .AllowAnyHeader()); 40 | 41 | // global error handler 42 | app.UseMiddleware(); 43 | 44 | // custom jwt auth middleware 45 | app.UseMiddleware(); 46 | 47 | app.MapControllers(); 48 | } 49 | 50 | // create hardcoded test users in db on startup 51 | { 52 | var testUsers = new List 53 | { 54 | new User { Id = 1, FirstName = "Admin", LastName = "User", Username = "admin", PasswordHash = BCryptNet.HashPassword("admin"), Role = Role.Admin }, 55 | new User { Id = 2, FirstName = "Normal", LastName = "User", Username = "user", PasswordHash = BCryptNet.HashPassword("user"), Role = Role.User } 56 | }; 57 | 58 | using var scope = app.Services.CreateScope(); 59 | var dataContext = scope.ServiceProvider.GetRequiredService(); 60 | dataContext.Users.AddRange(testUsers); 61 | dataContext.SaveChanges(); 62 | } 63 | 64 | app.Run("http://localhost:4000"); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dotnet-6-role-based-authorization-api 2 | 3 | .NET 6.0 - Role Based Authorization API 4 | 5 | Documentation at https://jasonwatmore.com/post/2022/02/18/net-6-role-based-authorization-tutorial-with-example-api 6 | 7 | Documentación en español en https://jasonwatmore.es/post/2022/02/18/net-6-tutorial-de-autorizacion-basada-en-roles-con-api-de-ejemplo -------------------------------------------------------------------------------- /Services/UserService.cs: -------------------------------------------------------------------------------- 1 | namespace WebApi.Services; 2 | 3 | using BCrypt.Net; 4 | using Microsoft.Extensions.Options; 5 | using WebApi.Authorization; 6 | using WebApi.Entities; 7 | using WebApi.Helpers; 8 | using WebApi.Models.Users; 9 | 10 | public interface IUserService 11 | { 12 | AuthenticateResponse Authenticate(AuthenticateRequest model); 13 | IEnumerable GetAll(); 14 | User GetById(int id); 15 | } 16 | 17 | public class UserService : IUserService 18 | { 19 | private DataContext _context; 20 | private IJwtUtils _jwtUtils; 21 | private readonly AppSettings _appSettings; 22 | 23 | public UserService( 24 | DataContext context, 25 | IJwtUtils jwtUtils, 26 | IOptions appSettings) 27 | { 28 | _context = context; 29 | _jwtUtils = jwtUtils; 30 | _appSettings = appSettings.Value; 31 | } 32 | 33 | 34 | public AuthenticateResponse Authenticate(AuthenticateRequest model) 35 | { 36 | var user = _context.Users.SingleOrDefault(x => x.Username == model.Username); 37 | 38 | // validate 39 | if (user == null || !BCrypt.Verify(model.Password, user.PasswordHash)) 40 | throw new AppException("Username or password is incorrect"); 41 | 42 | // authentication successful so generate jwt token 43 | var jwtToken = _jwtUtils.GenerateJwtToken(user); 44 | 45 | return new AuthenticateResponse(user, jwtToken); 46 | } 47 | 48 | public IEnumerable GetAll() 49 | { 50 | return _context.Users; 51 | } 52 | 53 | public User GetById(int id) 54 | { 55 | var user = _context.Users.Find(id); 56 | if (user == null) throw new KeyNotFoundException("User not found"); 57 | return user; 58 | } 59 | } -------------------------------------------------------------------------------- /WebApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0 4 | enable 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AppSettings": { 3 | "Secret": "THIS IS USED TO SIGN AND VERIFY JWT TOKENS, REPLACE IT WITH YOUR OWN SECRET, IT CAN BE ANY STRING" 4 | }, 5 | "Logging": { 6 | "LogLevel": { 7 | "Default": "Information", 8 | "Microsoft.AspNetCore": "Warning" 9 | } 10 | } 11 | } --------------------------------------------------------------------------------