Getting started with Identity in ASP.NET Core
Identity is a simple auth system and a great improvement over Simple Membership. If you're new to it, this exercise should help you get started.
This exercise
What we'll build: A simple auth controller using identity with login/logout actions for use by an SPA.
GitHub: https://github.com/gldraphael/dotnet-identity-example
Tools:
- VS Code (or Visual Studio)
- dotnet CLI 2.x (requires dotnet 2.x SDK)
- MySQL (or Postgres / SQL Server)
Prerequisites:
- Basic understanding of ASP.NET Core
- Basic understanding of EntityFramework Core
Create the project
Paste the following self-explanatory block into your terminal after cd
ing to an appropriate directory:
# Create the solution directory
mkdir dotnet-identity-example
cd dotnet-identity-example
# Create the Web project using an empty webapi template
dotnet new webapi --name Web
# Create a solution file and add the web project to it
dotnet new sln
dotnet sln add Web/Web.csproj
# Restore dependencies and run the project
dotnet restore
cd Web
dotnet run
Browse to http://localhost:5000/api/Values
and you should see a JSON response if everything worked well so far. (The webapi template adds a sample controller in Controllers/ValuesController.cs
which is why this works.)
Setup VS Code (Optional)
Type code ..
in the terminal to open the solution directory in VS Code. You'll be prompted with a dialog saying: Required assets to build and debug are missing from 'dotnet-identity-example'. Add them?
Answer Yes. This will add a .vscode
folder with a build task and two launch configurations. You'll now be able to use F5
to debug the application within VS Code. Just remember to open the solution directory everytime, not the project directory.
Visual Studio (Optional)
Just open the dotnet-identity-example.sln
file with Visual Studio. You can now use F5
to debug as usual (Cmd+Return on VS for Mac).
Add the User Model
Add the User model that extends the IdentityUser
class:
public class ApplicationUser : IdentityUser
{
[Required]
public string FullName { get; set; }
}
In this case I've added only a FullName
property to keep things simple. In case you need more properties for a user (eg. DateOfBirth
), this is where you need to add them. Fields like the Id
, UserName
, Email
, PasswordHash
, etc. are defined in IdentityUser
, so you don't need to re-define them. Here's the source.
You can also subclass the IdentityRole
class if you'd like to customize it with additional fields. IdentityRole.cs source.
Now add an empty ApplicationDbContext
with the appropriate constructors as follows:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext()
{
}
public ApplicationDbContext(DbContextOptions options)
: base(options)
{
}
}
Notice how the ApplicationDbContext
class extends IdentityDbContext<>
. IdentityDbContext<>
declares the necessary tables (as DbSet<>
s) required by Identity, including the tables for Roles, Claims, Logins, etc.
Check IdentityDbContext.cs and IdentityUserContext.cs for more information.
Configuring EF
Configuring the database
First, add the ConnectionStrings
block to the appsettings.Development.json
file's root object:
"ConnectionStrings": {
"database": "your-connection-string-here"
},
Remember to put your connection string in there. I'm using MySQL and my appsettings file looks like this.
I've tried and added steps for a few database providers. You should be able to figure your preferred (relational) database out, even if it's not on the list. This post assumes that you already know to configure EF anyway 🙃
Installing the nuget package for MySQL / Postgres
Ensure you're in the Web/
project directory and type the following:
# For MySQL
dotnet add package Pomelo.EntityFrameworkCore.MySql
# For Postgres
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
# Restore the nuget packages
cd ..
dotnet restore
Registering the DbContext
Register the ApplicationDbContext
we wrote earlier, as a dependency. Add the following to the beginning of ConfigureServices()
in the Startup.cs
file:
services.AddDbContext<ApplicationDbContext>(
options => options.UseSqlServer(Configuration.GetConnectionString("database")),
ServiceLifetime.Scoped);
- For SqlLite replace
UseSqlServer()
withUseSqlLite()
. - For an InMemory database for testing replace
UseSqlServer()
withUseInMemoryDatabase()
. I do not recommend using it for this exercise because you won't be able to query the database and see what's happening. - For MySQL use
UseMySQL()
instead. - For Postgress use
UseNpgsql()
.
Creating the tables
Add a DotNetCliToolReference
to the Web.csproj
file for dotnet ef
commands to work. The <ItemGroup>
with <DotNetCliToolReference>
tags should look something like this (with the first child element being the one I manually added):
<ItemGroup>
<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="2.0.0" />
<DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="2.0.0" />
</ItemGroup>
Here's the diff with the above change. This is also documented in Microsoft Docs here.
Run the following in the Web/
project directory:
cd Web
# Create a migration
dotnet ef migrations add InitialCreate
# Update the database with the migration
dotnet ef database
You can confirm if it worked by checking your database. You should see 8 tables (including the _EFMigrationsHistory
table).
Here's a database diagram of the tables (excluding _EFMigrationsHistory
) created using EF Core Power Tools by Erik EJ:
Table | Description |
---|---|
AspNetUsers |
Users table to hold user related information. |
AspNetUserLogins |
Represents a login and its associated provider for a user. |
AspNetUserTokens |
Holds tokens issued by other services to users (social login services for example) |
AspNetUserClaims |
User related claims go here. |
AspNetRoles |
List of roles and associated information. |
AspNetUserRoles |
User-role mapping table for a normalized many-to-many relationship. |
AspNetRoleClaims |
Role related claims go here. |
The tables are just an implementation detail. When using Identity, you would mostly use these classes:
Class | Description |
---|---|
UserManager<> |
For user related functions |
RoleManager<> |
For role related functions |
SigninManager |
For login / logout, password checks, etc. |
IdentityUser<> |
Represents the AspNetUsers table. Can be subclassed. |
IdentityRole<> |
Represents the AspNetRoles table. Can be subclassed. |
All of these classes become available via DI once Identity has been configured and added as a service.
Configuring Identity
Configure and add the Identity service in the ConfigureServices()
after registering the ApplicationDbContext
:
// Configure Identity
services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
// Configure identity options here.
options.Password.RequireDigit = false;
options.Password.RequiredLength = 6;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>(); // Tell Identity which EF DbContext to use
We now need to include authentication in our application pipeline by adding the following right before app.UseMvc()
in the Startup.Configure()
method:
app.UseAuthentication();
By default Identity performs a 302 redirect to a login page for unauthenticated or unauthorized requests. While this behaviour is desirable for websites, an SPA might want to handle this locally. We can override the default authentication events by configuring the Application Cookie as follows:
// Configure the Application Cookie
services.ConfigureApplicationCookie(options => {
// Override the default events
options.Events = new CookieAuthenticationEvents
{
OnRedirectToAccessDenied = ReplaceRedirectorWithStatusCode(HttpStatusCode.Forbidden),
OnRedirectToLogin = ReplaceRedirectorWithStatusCode(HttpStatusCode.Unauthorized)
};
// Configure our application cookie
options.Cookie.Name = ".applicationname";
options.Cookie.HttpOnly = true; // This must be true to prevent XSS
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.None; // Should ideally be "Always"
options.SlidingExpiration = true;
});
The ReplaceRedirectorWithStatusCode()
method returns a no-content HTTP response with an appropriate HTTP status code:
static Func<RedirectContext<CookieAuthenticationOptions>, Task> ReplaceRedirectorWithStatusCode(HttpStatusCode statusCode) => context =>
{
// Adapted from https://stackoverflow.com/questions/42030137/suppress-redirect-on-api-urls-in-asp-net-core
context.Response.StatusCode = (int) statusCode;
return Task.CompletedTask;
};
You might also want to adjust the CORs policy to whitelist your domain for SPAs. Here are the steps.
Seed a default user
EF Core currently doesn't currently support Initialization Strategies. For the sake of this exercise, we use UserManager<>
and RoleManager<>
to seed the users and roles.
First, let's add an enum
with three roles: Admin
, Role1
, Role2
.
public enum Role
{
Admin,
Role1,
Role2
}
public static class RoleExtensions
{
public static string GetRoleName(this Role role) // convenience method
{
return role.ToString();
}
}
Now we update the Main
method to resemble the following:
public static async Task Main(string[] args)
{
// Build the application host
var host = BuildWebHost(args);
// Create a scope
using(var scope = host.Services.CreateScope())
{
var userManager = scope.ServiceProvider.GetService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetService<RoleManager<IdentityRole>>();
// TODO: Add seed logic here
// For full code see: https://github.com/gldraphael/dotnet-identity-example/blob/master/Web/Program.cs
}
// Run the application
host.Run();
}
Add the seed logic from here in place of the TODO.
Run the application. The seed data should be populated in the database. You can now run a quick query to check if the database was properly seeded:
SELECT * FROM AspNetRoles;
SELECT * FROM AspNetUsers;
SELECT * from AspNetUserRoles;
So at the point, you've got Identity setup and some seed data populated.
The auth controller
Add the Login request ViewModel
Add a LoginViewModel
class preferrably to a ViewModels/Auth
folder in the project directory. The viewmodels will have the data required to perform a user login, which in most cases is a username (email address) and a password:
public class LoginViewModel
{
[Required]
[EmailAddress]
public string Username { get; set; }
[Required]
[DataType(DataType.Password)]
public string Password { get; set; }
}
Add the login and logout actions
Create an Auth controller in the Controllers folder:
[Route("api/auth")]
public class AuthController : Controller
{
private readonly SignInManager<ApplicationUser> _signInManager;
public AuthController(SignInManager<ApplicationUser> signInManager)
{
_signInManager = signInManager;
}
// POST: /api/auth/login
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody]LoginViewModel vm)
{
// Validate the requests
if (!ModelState.IsValid)
{
return BadRequest(); // TODO: Return error description
}
var result = await _signInManager.PasswordSignInAsync(
userName: vm.Username,
password: vm.Password,
isPersistent: true, // TODO: Get this from the viewmodel
lockoutOnFailure: true
);
if (result.RequiresTwoFactor)
{
return StatusCode(StatusCodes.Status501NotImplemented);
}
if (result.IsLockedOut)
{
return StatusCode(StatusCodes.Status423Locked);
}
if (result.Succeeded)
{
return Ok();
}
return Unauthorized();
}
// POST: /api/auth/logout
[Authorize, HttpPost("logout")]
public async Task<IActionResult> Logout()
{
await _signInManager.SignOutAsync();
return Ok();
}
}
Sending the following request should return a HTTP 200, and should set the application cookie:
POST: /api/auth/login; Content-Type: application/json
{
"userName": "[email protected]",
"password": "5ESTdYB5cyYwA2dKhJqyjPYnKUc&45Ydw^gz^jy&FCV3gxpmDPdaDmxpMkhpp&9TRadU%wQ2TUge!TsYXsh77Qmauan3PEG8!6EP"
}
And sending the following should unset the cookie:
POST: /api/auth/logout
If you now add the [Authorize(Roles = "Role1")]
attribute to the ValuesController
, those actions will now only be accessible to logged in users that belong to Role1
. (Pro tip: use a custom authorize attribute to keep your code clean. Here's how.)
Introduction to claims
Try the following:
- Login using the Login API we just created.
- Perform
GET: /api/values
. It should work, if it doesn't make sure that your HTTP client is sending cookies and try again from step 1. (Postman sends cookies out of the box.) - Now disconnect/shutdown/drop the database.
- Perform
GET: /api/values
. It will still work. - Now perform a logout with
POST: /api/auth/logout
. This will unset the cookie. - Now try
GET: /api/values
again, and you'll see a 401. - You can't login again until you turn the database on again (or recreate it using
dotnet ef database update
), so please do whatever it takes to get things working again 😬
Notice how step 4 worked even though the database was disconnected? That route had an [Authorize(Roles="Role1")]
which means that the role of the user accessing the API was being verified correctly without using the database. This was done using the claims present in the application cookie in the form of a bearer token.
Update the ValuesController.Get()
method's body with the following to return the user's default claims:
[HttpGet]
public IActionResult Get()
{
return Ok(User.Claims.Select(c => new {
c.Type,
c.Value
}));
}
The User
property used above is populated in the AuthenticationMiddleware we added to the pipeline using .UseAuthentication()
. If you're interested in the sources this is a good starting point: AuthenticationMiddleware.cs
GET: /api/values
will now give a response similar to the following (you need to login using the login API first, ofcourse):
[
{
"type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
"value": "2b02fd68-d36e-4274-a1cd-a00a8bfcecd6"
},
{
"type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
"value": "[email protected]"
},
{
"type": "AspNet.Identity.SecurityStamp",
"value": "e929e5b3-b4d6-4733-ad66-8f9ef7d965fc"
},
{
"type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
"value": "Admin"
},
{
"type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
"value": "Role1"
},
{
"type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
"value": "Role2"
}
]
Notice how the roles of the user are present as claims?
You can also add custom claims and implement claims-based authorization. Claims can be attached to both Users and Roles. (Refer to the database diagram above and it'll make sense.)
On a sidenote ASP.NET Core has a lot more authentication options out of the box compared to ASP.NET MVC 5. And you can use policies to mix and match. Super flexible if you ask me.
Next Steps
There's a lot more I'd love to cover, but this post has already gotten too long. These should help you get going in the right direction:
- 📓 Read on ASP.NET Core's underlying Auth System. Here's a great article by David McCullough.
- 👩 Write APIs to manage users and their roles.
- 📰 Add social logins and try to understand how things are being handled.
- 🔑 The primary keys of Identity's tables use GUID strings by default. Try using integers.
- ✉️ Email verification & 2FA.
- 🎫 Experiment with claims and auth policies.
- 🍪 Configure the app to use AntiForgery tokens since we're uses cookies.
- 💡 Identity is open source. Dig into it for some aha moments. (Also check the ASP.NET Security project.)
- 🐞 Spot the bug in the seed logic and find out why it's happening.
- 📱 Don't send cookies to non-web clients. Also look into IdentityServer.
- 🚒 For the brave try replacing EF with something else like Dapper. Identity seems to be quite flexible, perhaps try getting it to work with a document based database like MongoDb using a custom implementation?
Also, Microsoft Docs is awesome and has Identity documented very well 🙌.
Update 12 Aug 2018: Get rid of the SeedController and perform seeding in the Main method instead.