Securing Asp.Net Core Web API with Identity Server (Part 2)
July 11, 2020 4 Comments
This is part 2 of a 5 part series:
- Part 1: Setting up and configuring Identity Server
- Part 2: Moving Identity Server configuration to the database using Entity Framework Core
- Part 3: Configuring ASP.Net Identity to work with Identity Server using Entity Framework Core
- Part 4: User authentication and authorization with Identity Server
- Part 5: Creating, configuring and securing an ASP.Net Core API (using Swagger UI)
In the previous post, we looked at how to setup Identity Server with a bare bone ASP.Net core application.
In production, more often than not, you would have your configurations (clients, scopes, resources etc.) in a database rather than defined in code. It makes sense, and gives more flexibility and scalability when integrating authentication and authorization flows.
In this part, we will look at moving our Identity Server configurations into a more persistent store. We will be using SQL Server, but the data store can be of your choosing. So let’s get started.
Install the following nuget packages into IdentityServer.csproj project:
- dotnet add package IdentityServer4.EntityFramework
- dotnet add package Microsoft.EntityFrameworkCore.SqlServer
Next, we need to define a connection string in appsettings.json file that points to the SQL server instance on your machine.
"ConnectionStrings": {
"IdentityDbConnectionName": "Server=localhost\\SQLEXPRESS;Database=IdentityDb;Trusted_Connection=True;"
},
"WeatherApiClient": "http://localhost:5001"
Next, we modify the Startup.cs from the earlier post to configure Identity Server’s Configuration and Operational store to point to the database as defined in the connection string.
private static DbContextOptionsBuilder AddDbContext(DbContextOptionsBuilder builder,
IConfiguration configuration) =>
builder.UseSqlServer(configuration.GetConnectionString("IdentityDbConnectionName"),
sqlOptions =>
{
sqlOptions.MigrationsAssembly(typeof(Startup).Assembly.FullName);
sqlOptions.EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), null);
});
public void ConfigureServices(IServiceCollection services)
{
services.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = builder => AddDbContext(builder, Configuration);
})
.AddOperationalStore(options =>
{
options.ConfigureDbContext = builder => AddDbContext(builder, Configuration);
});
services.AddControllers();
services.AddControllersWithViews();
services.AddRazorPages();
}
Now that this is in place, let’s create a Data seeder class that will seed the initial Identity Server configuration data to the database. We will use the configuration data defined earlier in the Config.cs.
Pay attention to line 5. (I have added the URL for the Weather API (that we will create in part 4) to appsettings.json)
public class ConfigurationDataSeeder
{
public async Task SeedAsync(ConfigurationDbContext context, IConfiguration configuration)
{
var clientUrl = configuration.GetValue<string>("WeatherApiClient");
if (!context.Clients.Any())
{
foreach (var client in Config.Clients(clientUrl))
await context.Clients.AddAsync(client.ToEntity());
await context.SaveChangesAsync();
}
else
{
var oldRedirects = (await context.Clients.Include(c => c.RedirectUris)
.ToListAsync())
.SelectMany(c => c.RedirectUris)
.Where(ru => ru.RedirectUri.EndsWith("/o2c.html"))
.ToList();
if (oldRedirects.Any())
{
foreach (var redirectUri in oldRedirects)
{
redirectUri.RedirectUri = redirectUri.RedirectUri.Replace("/o2c.html", "/oauth2-redirect.html");
context.Update(redirectUri.Client);
}
await context.SaveChangesAsync();
}
}
if (!context.IdentityResources.Any())
{
foreach (var resource in Config.Resources())
await context.IdentityResources.AddAsync(resource.ToEntity());
await context.SaveChangesAsync();
}
if (!context.ApiResources.Any())
{
foreach (var api in Config.Apis())
await context.ApiResources.AddAsync(api.ToEntity());
await context.SaveChangesAsync();
}
if (!context.ApiScopes.Any())
{
foreach (var scope in Config.Scopes())
await context.ApiScopes.AddAsync(scope.ToEntity());
await context.SaveChangesAsync();
}
}
}
The next order of business, is to make sure that the seeder is run at application startup. Running the seeder will do a few things:
- Create the database if it doesn’t exist (this is only done once)
- Create the tables required by the ConfigurationDbContext and PersistedGrantDbContext in the database
Program.cs
public static async Task Main(string[] args)
{
var configuration = GetConfiguration();
Log.Logger = CreateLogger(configuration);
try
{
Log.Information("Starting configuring the host...");
var host = CreateHostBuilder(args).Build();
Log.Information("Starting applying database migrations...");
using var scope = host.Services.CreateScope();
var configurationContext = scope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
await configurationContext.Database.EnsureCreatedAsync();
await new ConfigurationDataSeeder().SeedAsync(configurationContext, configuration);
await configurationContext.Database.MigrateAsync();
var persistedGrantDbContext = scope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>();
await persistedGrantDbContext.Database.EnsureCreatedAsync();
await persistedGrantDbContext.Database.MigrateAsync();
Log.Information("Starting the host...");
await host.RunAsync();
}
catch (Exception exception)
{
Log.Fatal(exception, "Program terminated unexpectedly");
throw;
}
finally { Log.CloseAndFlush(); }
}
And that’s about it! When you run the application, it will create migrations at runtime and apply them to the database, creating all the required tables (including the database).
My Github repository is updated with the code and some more optimizations. Check it out here.
Part 3 coming soon!!