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 cding 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() with UseSqlLite().
  • For an InMemory database for testing replace UseSqlServer() with UseInMemoryDatabase(). 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:

identity-dgml

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:

  1. Login using the Login API we just created.
  2. 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.)
  3. Now disconnect/shutdown/drop the database.
  4. Perform GET: /api/values. It will still work.
  5. Now perform a logout with POST: /api/auth/logout. This will unset the cookie.
  6. Now try GET: /api/values again, and you'll see a 401.
  7. 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.