How To Create Role-Based Web API with ASP.NET Core
Here’s another great how-to blog post. It will show you how to use ASP.NET Core to build role-based Web API and Swagger UI to visualize and interact with endpoints.
When it comes to building a role-based Web API with ASP.NET Core, the code-first approach can be a powerful and efficient method. Using it, we can define our data models and relationships in code and then generate the corresponding database schema automatically. What does this lead to? Faster development cycles and greater flexibility for sure. How come? Because changes to the data model can be made quickly and easily, without having to modify the database schema directly. You can read more about Design First and Code First approaches on swagger.io.
In this tutorial, then, we will cover the steps for creating a role-based Web API using ASP.NET Core 6. We’ll be using Swagger UI to visualize and interact with our endpoints and MS SQL Server as our database. The application will include an authentication module and an event module. Logged-in users will be able to view the events associated with their account, while users with the Administrator role can create, update, and delete events.
Let’s get started!
Project Setup
First, we need to set up our project. To do it, open Visual Studio, go to create a new project and then choose ASP.NET Core Web API.
Choose the name of the application and click Next.
Setting Up API Database
After we have initialized our application, we need to configure the database. We are going to use EntityFrameworkCore as ORM so it will help us manage our database. For this reason, we should install a few packages.
The next thing to do after we have successfully installed the packages is to create a DbContext. Create DataContext.cs file and inherit DBContext class. Here we are going to define our tables.
public class DataContext: DbContext { public DataContext(DbContextOptions options): base(options) { } //Define our tables }
Then we should open Program.cs file and add the dbContext. We should specify dbProvider and connection string that are coming from appsettings.json file. The dbProvider can be SqlServer, MySql or InMemory.
// Add Db context var dbProvider = builder.Configuration.GetConnectionString("Provider"); builder.Services.AddDbContext < DataContext > (options => { if (dbProvider == "SqlServer") { options.UseSqlServer(builder.Configuration.GetConnectionString("SqlServerConnectionString")); } });
Make sure you have added the ConnectionString and Provider in your appsettings.json file as follows:
… "ConnectionStrings": { "Provider": "SqlServer", "SqlServerConnectionString": "Data Source=(localdb)\\MSSQLLocalDB;Database=HRApplication2;Integrated Security=True;Connect Timeout=30; " }, …
After configuring DbContext, it is necessary to generate the database models. In this case, we require two entities – User and Event – and a third table – UserEvent – to establish a many-to-many relationship between them. To accomplish this, it is recommended that we create a Models folder and a DbModels subfolder within it where we can create our database entities.
Let’s start with User model. Each user should have a unique ID, Email, FirstName, LastName, Password which will be stored in a hashed format, Role which can be User and Administrator for the demo, and UserEvents which will be related to UserEvent table.
public class User { public string UserId { get; set; } public string Email { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string Password { get; set; } public string Role { get; set; } public IList <UserEvent> UserEvents { get; set; } }
The Event model should also have unique ID, Title, Category, Date, and also relation to UserEvents table.
public class Event { public string Id { get; set; } public string Title { get; set; } public string Category { get; set; } public DateTime Date { get; set; } public IList <UserEvent> UserEvents { get; set; } }
Since a user can attend multiple events and an event can be attended by multiple users, we need to establish a many-to-many relationship between these entities. To do this, we’ll create an additional table called UserEvents. This table will include UserId and EventId columns which will establish the relationship between the User and Event entities.
public class UserEvent { public string UserId { get; set; } public User User { get; set; } public string EventId { get; set; } public Event Event { get; set; } }
Once we have created our database models, the next step is to register them in our DbContext. To achieve this, we can navigate to the DataContext.cs file, add all the entities as DbSets, and declare our relationships and primary keys. This can be accomplished by overriding the OnModelCreating method and utilizing the Fluent API to configure the relationships and keys. Once completed, the result should appear as follows:
public class DataContext: DbContext { public DataContext(DbContextOptions options): base(options) { } public DbSet <User> Users { get; set; } public DbSet < Event > Events { get; set; } public DbSet < UserEvent > UserEvents { get; set; } protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.Entity<User>() .HasKey(u => new { u.UserId }); builder.Entity<Event>() .HasKey(e => new { e.Id }); builder.Entity<UserEvent>() .HasKey(ue => new { ue.UserId, ue.EventId }); builder.Entity<UserEvent>() .HasOne(ue => ue.User) .WithMany(user => user.UserEvents) .HasForeignKey(u => u.UserId); builder.Entity<UserEvent>() .HasOne(uc => uc.Event) .WithMany(ev => ev.UserEvents) .HasForeignKey(ev => ev.EventId); } }
After we are ready with the database design, we should generate an initial migration that will create the database.
Open Package Manager Console and write the command:
Add-Migration InitialCreate
After it is successfully executed, we should update the database with:
Update-Database
Then with Microsoft SQL Management Studio, you should see the newly created database.
Configuring AutoMapper
AutoMapper will help us transform one model into another. This is going to convert the input models into dbModels. The reason we are doing this is that we might not need all properties from one of the models to be included in the other model. You will see how exactly we are going to use it later in the tutorial. Before that, we first need to configure it. You can find more detailed explanation of AutoMapper in the official documentation.
To begin, we must install the AutoMaper NuGet package. Following this, we can generate a MappingProfiles.cs file to define all mappings. It is recommended to create this file under a Helpers folder for organization purposes.
To declare our mappings, MappingProfiles should inherit the Profile class and we can declare our mappings using the CreateMap<from, to>() method. If we require the ability to map models in the opposite direction, we can include the .ReverseMap() method.
Once we have completed our mappings, we must navigate to the Program.cs file and register AutoMapper with our MappingProfiles.
… var config = new MapperConfiguration(cfg => { cfg.AddProfile(new MappingProfiles()); }); var mapper = config.CreateMapper(); builder.Services.AddSingleton(mapper); …
Setting Up Authentication
We are going to use JWT tokens for authentication. They provide us with a way to securely transmit information between parties as JSON object. You can read more about JWT tokens here. To use them, we must first install the necessary NuGet packages. We require both Microsoft.IdentityModel.Tokens and Microsoft.AspNetCore.Authentication.JwtBearer.
Next, we must define some token configurations in the appsettings.json file. These configurations include the Issuer, Audience, and SecretKey.
"Jwt": { "Issuer": "https://localhost:7244/", "Audience": "https://localhost:7244/", "Key": "S1u*p7e_r+S2e/c4r6e7t*0K/e7y" }
Once the token configurations have been defined, we can configure the JWT service in the Program.cs file. This involves specifying the schema that will be used along with any necessary validation parameters.
… builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(o => { o.TokenValidationParameters = new TokenValidationParameters { ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey (Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])), ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = false, ValidateIssuerSigningKey = true }; }); …
Make sure you have also added app.UseAuthentication();
Setting Up Swagger
In order to test our application endpoints using Swagger UI, we must include app.UseSwaggerUI() in the Program.cs file.
After that, we must generate an AuthResponse filter to aid in testing our authenticated endpoints using JWT tokens. To accomplish this, we can create an AuthResponsesOperationFilter class that implements the IOperationFilter interface. The Apply method should include the necessary logic to add the AuthResponse filter to Swagger.
public class AuthResponsesOperationFilter: IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { var authAttributes = context.MethodInfo.DeclaringType.GetCustomAttributes(true) .Union(context.MethodInfo.GetCustomAttributes(true)) .OfType<AuthorizeAttribute>(); if (authAttributes.Any()) { var securityRequirement = new OpenApiSecurityRequirement() { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } }, new List<string>() } }; operation.Security = new List<OpenApiSecurityRequirement> { securityRequirement }; operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" }); } } }
After that, make sure you have added the filter as an option in Program.cs .AddSwaggerGen method.
builder.Services.AddSwaggerGen(option => { option.SwaggerDoc("v1", new OpenApiInfo { Title = "Northwind CRUD", Version = "v1" }); option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { In = ParameterLocation.Header, Description = "Please enter a valid token", Name = "Authorization", Type = SecuritySchemeType.Http, BearerFormat = "JWT", Scheme = "bearer" }); option.OperationFilter < AuthResponsesOperationFilter > (); } );
You can read more detailed explanation of “What is Swagger?” in the official documentation.
Register Endpoint
After we are done with the configurations, we can move on to creating the register endpoint. The first step is to generate a RegisterInputModel.cs file, which should be located under the Models/InputModels folder.
The registration process requires Email, FirstName, LastName, Password, and ConfirmedPassword fields. All of these fields are required, so we will include the [Required] attribute. We will also include the [EmailAddress] attribute for the Email field. We can add additional attributes, such as minimum and maximum length, as desired. However, for the purposes of this demonstration, we will stick with these attributes.
public class RegisterInputModel { [EmailAddress] public string Email { get; set; } [Required] public string FirstName { get; set; } [Required] public string LastName { get; set; } [Required] public string Password { get; set; } [Required] public string ConfirmedPassword { get; set; } }
Next, we must add a mapping to the MappingProfiles.cs file that will enable conversion between the RegisterInputModel and User models in both directions.
CreateMap<RegisterInputModel, User>().ReverseMap();
To maintain separation of concerns, we will create a Services folder. Each module will have its own service for interacting with the database. We can begin by generating an AuthService.cs file and inject the DataContext and Configuration.
Our first method in AuthService.cs should be GenerateJwtToken, which takes the email and role as parameters and returns a JWT token containing user information.
public string GenerateJwtToken(string email, string role) { var issuer = this.configuration["Jwt:Issuer"]; var audience = this.configuration["Jwt:Audience"]; var key = Encoding.ASCII.GetBytes(this.configuration["Jwt:Key"]); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new [] { new Claim("Id", Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Sub, email), new Claim(JwtRegisteredClaimNames.Email, email), new Claim(ClaimTypes.Role, role), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }), Expires = DateTime.UtcNow.AddMinutes(5), Issuer = issuer, Audience = audience, SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha512Signature) }; var tokenHandler = new JwtSecurityTokenHandler(); var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); }
For hashing the password, we will be using BCrypt.Net.BCrypt. To begin, we must install the package and add it as a using statement at the beginning of the file.
using BC = BCrypt.Net.BCrypt;
Afterward, we will create several helper methods. One will verify if a user with a given email exists, another will authenticate the user, and two more will get a user by email and ID.
public bool IsAuthenticated(string email, string password) { var user = this.GetByEmail(email); return this.DoesUserExists(email) && BC.Verify(password, user.Password); } public bool DoesUserExists(string email) { var user = this.dataContext.Users.FirstOrDefault(x => x.Email == email); return user != null; } public User GetById(string id) { return this.dataContext.Users.FirstOrDefault(c => c.UserId == id); } public User GetByEmail(string email) { return this.dataContext.Users.FirstOrDefault(c => c.Email == email); }
Before we create the register method, we must first create a method to generate a unique ID. This method can be defined as follows:
public class IdGenerator { public static string CreateLetterId(int length) { var random = new Random(); const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; return new string(Enumerable.Repeat(chars, length) .Select(s => s[random.Next(s.Length)]).ToArray()); } }
Now we can proceed with the implementation of the Register method. Within this method, we will generate a unique ID, check if it already exists and, if so, generate a new one. Then, we will hash the user’s password and add the new user to the database.
public User RegisterUser(User model) { var id = IdGenerator.CreateLetterId(10); var existWithId = this.GetById(id); while (existWithId != null) { id = IdGenerator.CreateLetterId(10); existWithId = this.GetById(id); } model.UserId = id; model.Password = BC.HashPassword(model.Password); var userEntity = this.dataContext.Users.Add(model); this.dataContext.SaveChanges(); return userEntity.Entity; }
If you haven’t created AuthController already, now is the time. Go to Controllers folder and add AuthController which should inherit Controller class. We should also add [ApiController] attribute and [Route(“[controller]”)] so it is recognized by Swagger.
After that, we should inject the mapper, authService, and logger if we use one and create RegisterMethod. It should be post request accessible only by not authenticated users. It should accept RegisterInputModel as argument and check if the ModelState is valid. If so, it will generate jwt token for that user. The whole method should look like this:
[AllowAnonymous] [HttpPost("Register")] public ActionResult<string> Register(RegisterInputModel userModel) { try { if (ModelState.IsValid) { if (userModel.Password != userModel.ConfirmedPassword) { return BadRequest("Passwords does not match!"); } if (this.authService.DoesUserExists(userModel.Email)) { return BadRequest("User already exists!"); } var mappedModel = this.mapper.Map<RegisterInputModel, User>(userModel); mappedModel.Role = "User"; var user = this.authService.RegisterUser(mappedModel); if (user != null) { var token = this.authService.GenerateJwtToken(user.Email, mappedModel.Role); return Ok(token); } return BadRequest("Email or password are not correct!"); } return BadRequest(ModelState); } catch (Exception error) { logger.LogError(error.Message); return StatusCode(500); } }
Login Functionality
The Login functionality is similar except that we need to search for a user in the database. We should first create LoginInputModel.cs which this time will have only Email and Password fields. Don’t forget to add it also in the MappingProfiles.cs otherwise it won’t work.
public class LoginInputModel { [EmailAddress] [Required] public string Email { get; set; } [Required] public string Password { get; set; } }
Then in AuthController.cs create Login method which will take LoginInputModel as a parameter and will check if the user is authenticated. If so, it should generate a token. Otherwise, it should return an error.
[AllowAnonymous] [HttpPost("Login")] public ActionResult <string> Login(LoginInputModel userModel) { try { if (ModelState.IsValid) { if (this.authService.IsAuthenticated(userModel.Email, userModel.Password)) { var user = this.authService.GetByEmail(userModel.Email); var token = this.authService.GenerateJwtToken(userModel.Email, user.Role); return Ok(token); } return BadRequest("Email or password are not correct!"); } return BadRequest(ModelState); } catch (Exception error) { logger.LogError(error.Message); return StatusCode(500); } }
Adding CRUD for Events
After we are done with the authentication, we can develop the endpoints for the events. We are going to create full CRUD operations. Identically as with the users, we should create EventService.cs file. It will include a method for getting an event by ID, getting all events for a particular user, creating a new event, updating an existing event, and deleting an event. The whole file should look like this:
public class EventService { private readonly DataContext dataContext; public EventService(DataContext dataContext) { this.dataContext = dataContext; } public Event[] GetAllForUser(string email) { var user = this.dataContext.Users .FirstOrDefault(user => user.Email == email); return this.dataContext.Events .Include(ev => ev.UserEvents) .Where(e => e.UserEvents.FirstOrDefault(ue => ue.UserId == user.UserId) != null) .ToArray(); } public Event GetById(string id) { return this.dataContext.Events .Include(ev => ev.UserEvents) .FirstOrDefault(c => c.Id == id); } public Event Create(Event model) { var id = IdGenerator.CreateLetterId(6); var existWithId = this.GetById(id); while (existWithId != null) { id = IdGenerator.CreateLetterId(6); existWithId = this.GetById(id); } model.Id = id; var eventEntity = this.dataContext.Events.Add(model); this.dataContext.SaveChanges(); return eventEntity.Entity; } public Event Update(Event model) { var eventEntity = this.dataContext.Events .Include(ev => ev.UserEvents) .FirstOrDefault(c => c.Id == model.Id); if (eventEntity != null) { eventEntity.Title = model.Title != null ? model.Title : eventEntity.Title; eventEntity.Date = model.Date != null ? model.Date : eventEntity.Date; eventEntity.Category = model.Category != null ? model.Category : eventEntity.Category; eventEntity.UserEvents = model.UserEvents.Count! > 0 ? model.UserEvents : eventEntity.UserEvents; this.dataContext.SaveChanges(); } return eventEntity; } public Event Delete(string id) { var eventEntity = this.GetById(id); if (eventEntity != null) { this.dataContext.Events.Remove(eventEntity); this.dataContext.SaveChanges(); } return eventEntity; } }
Next, you’ll want to head over to the controller and set up a method for each request.
We’ll create an EventBindingModel that will be used to store all the necessary data from the Event model.
For the GetAll method, make sure it uses a GET request and retrieves the user’s token, decodes it, and fetches that user’s events.
[HttpGet] [Authorize] public ActionResult<EventBindingModel[]> GetAll() { try { var userEmail = this.authService.DecodeEmailFromToken(this.Request.Headers["Authorization"]); var events = this.eventService.GetAllForUser(userEmail); return Ok(this.mapper.Map<Event[], EventBindingModel[]> (events)); } catch (Exception error) { logger.LogError(error.Message); return StatusCode(500); } } … public string DecodeEmailFromToken(string token) { var decodedToken = new JwtSecurityTokenHandler(); var indexOfTokenValue = 7; var t = decodedToken.ReadJwtToken(token.Substring(indexOfTokenValue)); return t.Payload.FirstOrDefault(x => x.Key == "email").Value.ToString(); } …
Get by id should also be a GET request with id as a parameter.
[HttpGet("{id}")] [Authorize] public ActionResult<Event> GetById(string id) { try { var eventEntity = this.eventService.GetById(id); if (eventEntity != null) { return Ok(eventEntity); } return NotFound(); } catch (Exception error) { logger.LogError(error.Message); return StatusCode(500); } }
Delete endpoint will be DELETE request and also will take id as argument.
[HttpDelete("{id}")] [Authorize(Roles = "Administrator")] public ActionResult<Event> Delete(string id) { try { var eventEntity = this.eventService.Delete(id); if (eventEntity != null) { return Ok(eventEntity); } return NotFound(); } catch (Exception error) { logger.LogError(error.Message); return StatusCode(500); } }
To simplify the process of adding or updating event records, let’s create an EventInputModel specifically for Create and Update operations. This model will only require us to provide the essential properties for userEvents, including the title, category, date, userId, and eventId. By using this model, we eliminate the need to specify all properties of the Event model for each operation.
[HttpPost] public ActionResult<Event> Create(EventInputModel model) { try { if (ModelState.IsValid) { var mappedModel = this.mapper.Map < EventInputModel, Event > (model); var eventEntity = this.eventService.Create(mappedModel); return Ok(eventEntity); } return BadRequest(ModelState); } catch (Exception error) { logger.LogError(error.Message); return StatusCode(500); } }
Update will be PUT request and will also take EventInputModel as a parameter.
[HttpPut] public ActionResult<Event> Update(EventInputModel model) { try { if (ModelState.IsValid) { var mappedModel = this.mapper.Map<EventInputModel, Event>(model); var eventEntity = this.eventService.Update(mappedModel); if (eventEntity != null) { return Ok(eventEntity); } return NotFound(); } return BadRequest(ModelState); } catch (Exception error) { logger.LogError(error.Message); return StatusCode(500); } }
Adding Role-Based Authorization
To restrict certain actions to specific user roles, we can use role-based authorization. In our scenario, for example, we want to limit the access to the Create, Update, and Delete endpoints for events to users with an Administrator role.
To set this up, we’ll need to add app.UseAuthorization(); to our Program.cs file. Then, for each endpoint that requires restricted access, we’ll add the [Authorize] attribute, which will specify the allowed roles. For example, we can ensure that only Administrators can access the Delete endpoint.
… [Authorize(Roles = "Administrator")] public ActionResult<Event> Delete(string id) …
Creating DbSeeder
When running our application, we often want to have some data pre-populated in our database, whether for testing purposes or other reasons. This is where seeding comes in. To get started, we’ll need to define the data we want to use.
To do this, we can create a Resources folder and add two JSON files: one for users and one for events. These files should contain the data we want to populate in the database. For instance, our files might look something like this:
[ { "UserId": "USERABCDE", "FirstName": "Kate", "LastName": "Lorenz", "Password": "kate.lorenz", "Email": "klorenz@hrcorp.com", "Role": "Administrator" }, { "UserId": "ASERABCDE", "FirstName": "Anthony", "LastName": "Murray", "Password": "anthony.murray", "Email": "amurray@hrcorp.com", "Role": "User" } ]
Next, we should create a DbSeeder class that includes a Seed method. This method will read the data we defined earlier and populate it into the database. To do this, we’ll need to pass in the dbContext as a parameter.
public class DBSeeder { public static void Seed(DataContext dbContext) { ArgumentNullException.ThrowIfNull(dbContext, nameof(dbContext)); dbContext.Database.EnsureCreated(); var executionStrategy = dbContext.Database.CreateExecutionStrategy(); executionStrategy.Execute( () => { using(var transaction = dbContext.Database.BeginTransaction()) { try { // Seed Users if (!dbContext.Users.Any()) { var usersData = File.ReadAllText("./Resources/users.json"); var parsedUsers = JsonConvert.DeserializeObject <User[]>(usersData); foreach(var user in parsedUsers) { user.Password = BC.HashPassword(user.Password); } dbContext.Users.AddRange(parsedUsers); dbContext.SaveChanges(); } // Seed Events if (!dbContext.Events.Any()) { var eventsData = File.ReadAllText("./Resources/events.json"); var parsedEvents = JsonConvert.DeserializeObject <Event[]>(eventsData); dbContext.Events.AddRange(parsedEvents); dbContext.SaveChanges(); } transaction.Commit(); } catch (Exception ex) { transaction.Rollback(); } } }); } }
After that in our Helpers folder, we should create a database initializer extension, which is going to run the Seed method.
public static class DBInitializerExtension { public static IApplicationBuilder UseSeedDB(this IApplicationBuilder app) { ArgumentNullException.ThrowIfNull(app, nameof(app)); using var scope = app.ApplicationServices.CreateScope(); var services = scope.ServiceProvider; var context = services.GetRequiredService < DataContext > (); DBSeeder.Seed(context); return app; } }
Finally, we’ll need to open the Program.cs file and add the app.UseSeedDB() method. This ensures that when our application starts, it will check if there is any data in the database. If there isn’t, the Seed method we created earlier will automatically populate it with the data we defined.
Adding CORS
To enable cross-origin resource sharing (CORS) for our endpoints, we’ll need to add a cors service in the Program.cs file. In this case, we’ll allow access from any region but you can specify a specific domain if you prefer.
builder.Services.AddCors(policyBuilder => policyBuilder.AddDefaultPolicy(policy => policy.WithOrigins("*") .AllowAnyHeader() .AllowAnyHeader()) );
And after that add app.UseCors(); method.
This will allow us to access our API from a front-end application.
You can read more about Cross-Origin Resource Sharing (CORS) here.
To Sum It All Up…
Creating a role-based API with ASP .NET Core is a crucial aspect when it comes to building secure and scalable web applications. By using role-based authorization, you can control access to your API resources based on the roles assigned to your users. This ensures that only authorized users can access sensitive data or perform critical actions, making your application more secure and reliable. In this tutorial, we saw how to create a simple role-based API from 0 to 1. You can view the whole code of the demo on GitHub.
If you want to see how to connect the API created here and build a front-end application for it with App BuilderTM, read Part II of this blog post – Creating an App in App Builder Using Role-Based API.