In the last article I have shown how to quickly build a Backend and a Frontend using ASP.NET Core OData and Angular. But what's keeping us from putting it in production? We didn't add any authentication and authorization. Ouch.
What's the problem?
An OData query gives a client a lot of freedom. That's great! But it's maybe way too much freedom. Do you want to
allow all users to $expand
related entities without checking, if they are authorized to do so? What about using
OData navigation properties to address related data? We need to validate these requests.
The ASP.NET Core Authorization-middleware can only secure the access to an endpoint. That's totally fine, if you
disallow $expand
queries and do not include any navigation properties in your entity model. But for anything
more sophisticated... it won't work.
So this article introduces ODataAuthorization
, which uses the permissions defined in the capability annotations of
the OData model to apply authorization policies to an OData-enabled Web API . It is a fork of the WebApiAuthorization
library and basically just updates it to .NET 6 and ASP.NET Core OData 8.
So all credit goes to them!
All code can be found at:
Table of contents
What we are going to build
We are going to look at a small sample application, that has been written for Microsoft.AspNetCore.OData.Authorization
. So
again all credit goes to them.
The example shows how to secure an OData API using Cookue authentication and the capability annotations in the OData EDM model. The
Web API, that's going to be created has a single entity set Products
and only supports the basic CRUD requests.
A user querying the OData model has to provide the following claims for accessing the data:
Endpoint | Required permissions |
---|---|
`GET /odata/Products` | `Product.Read` |
`GET /odata/Products/1` | `Product.Read` or `Product.ReadByKey` |
`DELETE /odata/Products/1` | `Product.Delete` |
`POST /odata/Products` | `Product.Create` |
`PATCH /odata/Products(1)` | `Product.Update` |
The Postman application is used to query the Web API, which is ...
[...] an API platform for building and using APIs. Postman simplifies each step of the API lifecycle and streamlines collaboration so that you can create better APIs faster.
You can use the Postman collections provided in the /samples
folder of the project to get started.
CookieAuthenticationSample
We start by adding the ODataAuthorization
to the project:
> Install-Package ODataAuthorization -Version 1.0.0
Database Setup
We want to do CRUD operations on a Product
:
namespace ODataAuthorizationDemo.Models
{
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public int Price { get; set; }
}
}
Entity Framework Core is used, so we create DbContext
and add a DbSet<Product>
:
using Microsoft.EntityFrameworkCore;
namespace ODataAuthorizationDemo.Models
{
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Product> Products { get; set; }
}
}
And in the Startup the DbContext is configured to use an In-Memory Database:
namespace ODataAuthorizationDemo
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase("ODataAuthDemo"));
// ...
}
// ...
}
}
Authentication Endpoints
The idea of the sample application is to login with a set of requested scopes, that the user has:
So we add a LoginData
model:
namespace ODataAuthorizationDemo.Models
{
public class LoginData
{
public string[] RequestedScopes { get; set; }
}
}
This set of scopes is then passed to the AuthController
, which provides a /login
and /logout
endpoint. On login a ClaimsPrincipal
is
created with all requested scopes. Under the hood the ClaimsPrincipal
then is serialized, written into a Session Cookie and passed back to
the client.
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using ODataAuthorizationDemo.Models;
namespace ODataAuthorizationDemo.Controllers
{
[Route("[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
[HttpPost]
[Route("login")]
public async Task<IActionResult> Login([FromBody] LoginData data)
{
// create a claim for each request scope
var claims = data.RequestedScopes.Select(s => new Claim("Scope", s));
var claimsIdentity = new ClaimsIdentity(
claims, CookieAuthenticationDefaults.AuthenticationScheme);
var user = new ClaimsPrincipal(claimsIdentity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
user);
return Ok();
}
[HttpPost]
[Route("logout")]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(
CookieAuthenticationDefaults.AuthenticationScheme);
return Ok();
}
}
}
Now download the Postman Collection for the CookieAuthenticationSample and import it to Postman.
And executing the /auth/login
endpoint shows, that the response contains a Cookie named .AspNetCore.Cookies
with some Base64 value:
To logout make a POST /auth/logout
request.
Product Endpoints
And we finally add an ODataController
, that provides the CRUD enpoints for a Product. It uses the AppDbContext
for reading and writing to a database:
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData.Deltas;
using Microsoft.AspNetCore.OData.Routing.Controllers;
using ODataAuthorizationDemo.Models;
namespace ODataAuthorizationDemo.Controllers
{
public class ProductsController: ODataController
{
private AppDbContext _dbContext;
public ProductsController(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public IActionResult Get()
{
return Ok(_dbContext.Products);
}
public IActionResult Get(int key)
{
return Ok(_dbContext.Products.Find(key));
}
public async Task<IActionResult> Post([FromBody] Product product)
{
_dbContext.Products.Add(product);
await _dbContext.SaveChangesAsync();
return Ok(product);
}
public async Task<IActionResult> Patch(int key, [FromBody] Delta<Product> delta)
{
var product = await _dbContext.Products.FindAsync(key);
delta.Patch(product);
_dbContext.Products.Update(product);
await _dbContext.SaveChangesAsync();
return Ok(product);
}
public async Task<IActionResult> Delete(int key)
{
var product = await _dbContext.Products.FindAsync(key);
_dbContext.Products.Remove(product);
await _dbContext.SaveChangesAsync();
return Ok(product);
}
}
}
And that's it!
Configuring the OData Model
To secure the API we are going to use the restrictions vocabulary defined in the OData specification and assign Scope names to them.
The scopes are:
Scope | Description |
---|---|
`Product.Read` | Reads all products |
`Product.ReadByKey` | Read a single product by its key |
`Product.Create` | Creates a new product |
`Product.Update` | Updates a product |
`Product.Delete` | Deletes a product |
By using the ODataConventionModelBuilder
the implementation in the AppEdmModel
looks like this:
using Microsoft.OData.Edm;
using Microsoft.OData.ModelBuilder;
namespace ODataAuthorizationDemo.Models
{
public static class AppEdmModel
{
public static IEdmModel GetModel()
{
var builder = new ODataConventionModelBuilder();
var products = builder.EntitySet<Product>("Products");
products.HasReadRestrictions()
.HasPermissions(p =>
p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Read")))
.HasReadByKeyRestrictions(r => r.HasPermissions(p =>
p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.ReadByKey"))));
products.HasInsertRestrictions()
.HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Create")));
products.HasUpdateRestrictions()
.HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Update")));
products.HasDeleteRestrictions()
.HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Delete")));
return builder.GetEdmModel();
}
}
}
You can learn more about the available Restrictions here:
Registering the Middleware
Now in the Startup
we are adding Cookie Authentication and use the IServiceCollection#AddODataAuthorization
extension method to add OData Authorization. To resolve
the scopes available for a given ClaimsPrincipal
, you need to configure the ScopeFinder
for the OData authorization middleware:
// ...
namespace ODataAuthorizationDemo
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// ...
services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie((options) =>
{
options.AccessDeniedPath = string.Empty;
options.Events.OnRedirectToAccessDenied = (context) =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
options.Events.OnRedirectToLogin = (context) =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
});
services
.AddControllers()
// Add OData Routes:
.AddOData((opt) => opt
.AddRouteComponents("odata", AppEdmModel.GetModel())
.EnableQueryFeatures())
// Add OData Authorization:
.AddODataAuthorization((options) =>
{
options.ScopesFinder = context =>
{
// Select all "Scope" Claims of the ClaimsPrincipal:
var scopes = context.User
.FindAll("Scope")
.Select(claim => claim.Value);
return Task.FromResult(scopes);
};
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ...
app.UseAuthentication();
app.UseAuthorization();
// ...
}
}
Examples
Let's see how it works.
Product.ReadByKey
Imagine we want to allow the user to only read a Product by its key. This is useful, if a user should be able to get a single product, but
should not be able to query the entire EntitySet
.
We are sending the following /auth/login
request:
{
"RequestedScopes": ["Product.ReadByKey"]
}
A HTTP GET on /odata/Products
now returns a HTTP Status 403 (Forbidden), because we lack the Product.Read
scope:
A HTTP GET on /odata/Products(1)
for reading by key returns the HTTP Status 200 (OK) and contains the expected payload:
The endpoints for create, update and delete will also return a HTTP Status 403 (Forbidden), because we are not authorized.
Product.Create and Product.ReadByKey
Imagine we want to allow a user to also create a product. This requires us to pass the Product.Create
.
We are sending the following /auth/login
request:
{
"RequestedScopes": ["Product.Create", "Product.ReadByKey"]
}
The user is now allowed to create an entity by sending a HTTP POST to the /odata/Products
endpoint:
The fresh product can now be queried by calling /odata/Products(10)
:
Conclusion
This article introduced the ODataAuthorization
library for ASP.NET Core OData 8.
It provides a way to protected your OData API using scopes extracted from a ClaimsPrincipal
, so you can
restrict access to your data model in a similar way to the Microsoft Graph API.