Writing an ASP.NET Core Identity Storage Provider from Scratch with RavenDB

In this post, I will share how to write an ASP.NET Core Identity Storage Provider from the Scratch using RavenDB.

The source code is fully available on my Github.

What is ASP.NET Core Identity?

ASP.NET Core Identity is the membership system for building ASP.NET Core web applications, including membership, log in, and user data. ASP.NET Core Identity allows you to add login features to your application and makes it easy to customize data about the logged in user.

What is a storage provider?

The Storage Provider is a low-level component in the ASP.NET Core Identity architecture that provides classes that specify how users and roles are persisted.

By default, ASP.NET Core Identity stores user information in a SQL Server database using Entity Framework. However, you may prefer to use a different type of persistence strategy, such as RavenDB database. In this case, you will need to use/write a customized provider for your storage mechanism and plug that provider into your application.

The storage provider should provide implementations for two interfaces: IUserStore and IRoleStore.

Starting your own ASP.NET Core Identity Storage Provider

I strongly recommend you to create dedicated .net standard library Project to accommodate your implementation. This is not mandatory, but it will help you to maintain some code organization and reuse.

To implement your own storage provider, you will need to install the Microsoft.AspNetCore.Identity NuGet package in your project. In this post, I will use RavenDB as the persistence mechanism, so I will install the RavenDB.Client NuGet package as well.

Supporting basic user data management

The most important element of a Storage Provider implementation is the User Store. The User Store is a class that provides methods for all data operations on the user.

A minimal user store needs to implement the IUserStore interface.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.AspNetCore.Identity
{
    public interface IUserStore<TUser> : IDisposable where TUser : class
    {
        Task<string> GetUserIdAsync(TUser user, CancellationToken cancellationToken);
        Task<string> GetUserNameAsync(TUser user, CancellationToken cancellationToken);
        Task SetUserNameAsync(TUser user, string userName, CancellationToken cancellationToken);
        Task<string> GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken);
        Task SetNormalizedUserNameAsync(TUser user, string normalizedName, CancellationToken cancellationToken);
        Task<IdentityResult> CreateAsync(TUser user, CancellationToken cancellationToken);
        Task<IdentityResult> UpdateAsync(TUser user, CancellationToken cancellationToken);
        Task<IdentityResult> DeleteAsync(TUser user, CancellationToken cancellationToken);
        Task<TUser> FindByIdAsync(string userId, CancellationToken cancellationToken);
        Task<TUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken);
    }
}

There are methods to create, update, delete and retrieve users. There are also methods to get and set values from the user object.

The User object

There are no constraints to define the public interface of the user object and that is really exciting. The developer is free to define the user object in the way he wants. The basic implementation requires just three properties. So, in this example, let’s start simple.

public class RavenDBIdentityUser
{
    public RavenDBIdentityUser(string userName) : this()
    {
        UserName = userName ?? throw new ArgumentNullException(nameof(userName));
    }

    public string Id { get; internal set; }
    public string UserName { get; internal set; }
    public string NormalizedUserName { get; internal set; }
}

Getting things done to save and retrieve data

Now, it is time to start to implement our UserStore.

public partial class RavenDBUserStore<TUser, TDocumentStore> :
    IUserStore<TUser>,
    where TUser : RavenDBIdentityUser
    where TDocumentStore: class, IDocumentStore
{
    public IdentityErrorDescriber ErrorDescriber { get; }
    public TDocumentStore Context { get; }

    private readonly Lazy<IAsyncDocumentSession> _session;

    public RavenDBUserStore(
        TDocumentStore context,
        IdentityErrorDescriber errorDescriber = null
    )
    {
        ErrorDescriber = errorDescriber;
        Context = context ?? throw new ArgumentNullException(nameof(context));

        _session = new Lazy<IAsyncDocumentSession>(() =>
        {
            var session = Context.OpenAsyncSession();
            session.Advanced.UseOptimisticConcurrency = true;
            return session;
        }, true);
    }

    public IAsyncDocumentSession Session 
        => _session.Value;

    public Task SaveChanges(
        CancellationToken cancellationToken = default(CancellationToken)
        ) => Session.SaveChangesAsync(cancellationToken);

    // ...

    #region IDisposable
    private void ThrowIfDisposed()
    {
        if (_disposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }
    }

    private bool _disposed;
    public void Dispose()
    {
        Session.Dispose;
        _disposed = true;
    }
    #endregion
}

In this code, we start to implement the IUserStore interface.

I am using a generic parameter to specify the type of the user object that will be utilized. There is a constraint enforcing this type as a specialization of the RavenDBIdentityUser class. This design decision makes easy to extend the model to save additional data.

The constructor expects an IDocumentStore instance. We will use this object to connect to the RavenDB database. (If you want to learn more about RavenDB, subscribe the RavenDB bootcamp available for free online).

We will be using the RavenDB Async API that is pretty easy.

Creating, retrieving, updating and deleting users

With a session, it’s pretty easy to write the code that will create, retrieve and update users.

public async Task<IdentityResult> CreateAsync(
    TUser user,
    CancellationToken cancellationToken = default(CancellationToken)
    )
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();

    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }

    cancellationToken.ThrowIfCancellationRequested();

    await Session.StoreAsync(user, cancellationToken);
    await SaveChanges(cancellationToken);

    return IdentityResult.Success;
}

public Task<TUser> FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken))
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();

    cancellationToken.ThrowIfCancellationRequested();
    return Session.LoadAsync<TUser>(userId, cancellationToken);
}

public async Task<IdentityResult> UpdateAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken))
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();

    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }

    var stored = await Session.LoadAsync<TUser>(user.Id, cancellationToken);
    var etag = Session.Advanced.GetEtagFor(stored);

    await Session.StoreAsync(user, etag, cancellationToken);

    try
    {
        await SaveChanges(cancellationToken);
    }
    catch (ConcurrencyException)
    {
        return IdentityResult.Failed(ErrorDescriber.ConcurrencyFailure());
    }

    return IdentityResult.Success;
}

public async Task<IdentityResult> DeleteAsync(
    TUser user,
    CancellationToken cancellationToken = default(CancellationToken)
    )
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();

    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }

    Session.Delete(user.Id);

    try
    {
        await SaveChanges(cancellationToken);
    }
    catch (ConcurrencyException)
    {
        return IdentityResult.Failed(ErrorDescriber.ConcurrencyFailure());
    }

    return IdentityResult.Success;
}

We don’t need to think about table schemas – RavenDB is a schemaless database. RavenDB will save the object with no complaints, even if the class public interface changes completely. There is no need to think about modifications in this code in the future. There is no migrations or anything like that.

Finding users by name

Now, let’s write the FindByName method.

public Task<TUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken))
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();

    return Session.Query<TUser>().FirstOrDefaultAsync(
        u => u.NormalizedUserName == normalizedUserName, cancellationToken
    );
}

How fast is this code? Using Raven it is extremely fast! RavenDB will create an index automatically to execute this query as fast as possible.

Getting and setting

As you know, we are free to define the public interface of the user object. So, the IUserStore interface defines some helper methods where we can define the logic for getting and setting data.

public Task<string> GetUserIdAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken))
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }

    return Task.FromResult(user.Id);
}

public Task<string> GetUserNameAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken))
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();

    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }

    return Task.FromResult(user.UserName);
}

public Task SetUserNameAsync(TUser user, string userName, CancellationToken cancellationToken = default(CancellationToken))
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();

    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }

    if (userName == null)
    {
        throw new ArgumentNullException(nameof(userName));
    }

    user.UserName = userName;

    return Task.CompletedTask;
}

public Task<string> GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken))
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();

    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }

    return Task.FromResult(user.NormalizedUserName);
}

public Task SetNormalizedUserNameAsync(TUser user, string normalizedName, CancellationToken cancellationToken = default(CancellationToken))
{
    cancellationToken.ThrowIfCancellationRequested();
    ThrowIfDisposed();

    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }

    if (normalizedName == null)
    {
        throw new ArgumentNullException(nameof(normalizedName));
    }

    user.NormalizedUserName = normalizedName;

    return Task.CompletedTask;
}

Simple like that.

Supporting advanced user data management

As you can see, the basic implementation of the user store does not have support to some important concepts like passwords, emails, claims, users lockout, roles and so on.

All these features can be added as you need implementing some optional interfaces. These interfaces will just define other helper methods to get and set data from the user object.

You can find the implementation of these important optional interfaces on my GitHub.

Roles data management

A good Storage provider should provide a Role Store. The Role Store is a class that provides methods for all data operations on the Roles.

public interface IRoleStore<TRole> : IDisposable where TRole : class
{
    Task<IdentityResult> CreateAsync(TRole role, CancellationToken cancellationToken);
    Task<IdentityResult> DeleteAsync(TRole role, CancellationToken cancellationToken);
    Task<TRole> FindByIdAsync(string roleId, CancellationToken cancellationToken);
    Task<TRole> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken);
    Task<string> GetNormalizedRoleNameAsync(TRole role, CancellationToken cancellationToken);
    Task<string> GetRoleIdAsync(TRole role, CancellationToken cancellationToken);
    Task<string> GetRoleNameAsync(TRole role, CancellationToken cancellationToken);
    Task SetNormalizedRoleNameAsync(TRole role, string normalizedName, CancellationToken cancellationToken);
    Task SetRoleNameAsync(TRole role, string roleName, CancellationToken cancellationToken);
    Task<IdentityResult> UpdateAsync(TRole role, CancellationToken cancellationToken);
}

As you can see, the idea is pretty similar to IUserStore. So, we can follow the same implementation strategy.

You can find a example of IRoleStore implementation on my GitHub.

Helper methods to make the IdentityBuilder configuration easier

Now that we have IUserStore and IRoleStore implementations, we need to write a helper method to configure the IdentityBuilder to use these implementations.

public static class IdentityBuilderExtensions
{
    public static IdentityBuilder UseRavenDBDataStoreAdaptor<TDocumentStore>(
        this IdentityBuilder builder
    ) where TDocumentStore : class, IDocumentStore
        => builder
            .AddRavenDBUserStore<TDocumentStore>()
            .AddRavenDBRoleStore<TDocumentStore>();
        
    private static IdentityBuilder AddRavenDBUserStore<TDocumentStore>(
        this IdentityBuilder builder
    )
    {
        var userStoreType = typeof(RavenDBUserStore<,>).MakeGenericType(builder.UserType, typeof(TDocumentStore));

        builder.Services.AddScoped(
            typeof(IUserStore<>).MakeGenericType(builder.UserType),
            userStoreType
        );

        return builder;
    }

    private static IdentityBuilder AddRavenDBRoleStore<TDocumentStore>(
        this IdentityBuilder builder
    )
    {
        var roleStoreType = typeof(RavenDBRoleStore<,>).MakeGenericType(builder.RoleType, typeof(TDocumentStore));

        builder.Services.AddScoped(
            typeof(IRoleStore<>).MakeGenericType(builder.RoleType),
            roleStoreType
        );

        return builder;
    }
}

Done!

Using it!

We are ready to stop using Entity Framework and SQL Server (ugh) and start using RavenDB to store user and role data in our ASP.NET Core applications.

Assuming that you are already using RavenDB, the configuration will be something like that:

public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    //services.AddDbContext<ApplicationDbContext>(options =>
    //        options.UseSqlServer(Configuration["Data:DefaultConnection:ConnectionString"]));

    services.AddSingleton(DocumentStoreHolder.Store)

    services.AddIdentity<ApplicationUser, RavenDBIdentityRole>()
        .UseRavenDBDataStoreAdaptor<IDocumentStore>()
        .AddDefaultTokenProviders();

    services.AddMvc();

    // Add application services.
    services.AddTransient<IEmailSender, AuthMessageSender>();
    services.AddTransient<ISmsSender, AuthMessageSender>();
}

Please note that you will need to change the base class of the ApplicationUser to RavenDBIdentityUser.

There is a running demo on my GitHub.

Nice!

Compartilhe este insight:

6 respostas

  1. Why in both `CreateAsync` & `FindByIdAsync` methods
    `cancellationToken.ThrowIfCancellationRequested();`
    is called twice?

  2. Great article.

    Would it be possible to utilize RavenDB as UserStore, but keep UserRole in EF/ default implementation?

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Elemar Júnior

Sou fundador e CEO da EximiaCo e atuo como tech trusted advisor ajudando diversas empresas a gerar mais resultados através da tecnologia.

Elemar Júnior

Sou fundador e CEO da EximiaCo e atuo como tech trusted advisor ajudando diversas empresas a gerar mais resultados através da tecnologia.

Mais insights para o seu negócio

Veja mais alguns estudos e reflexões que podem gerar alguns insights para o seu negócio:

[tweet]Uma dos ganhos mais notáveis da Arquitetura Corporativa é a conexão que ela propicia entre a Estratégia e a Rotina[/tweet]....
Outro dia, meu amigo Giovanni Bassi compartilhou o seguinte tuíte: Ele e eu concordamos em discordar muitas vezes. Esta é...
No último sábado, comprei um “toca-discos”. A experiência de ouvir um LP é algo bem diferente de quase tudo que...
In this post, I would like to share my current reading list. If you are reading some of these books,...
In the previous post, you learned how to install RavenDB on your computer, create a database and load sample data....
Aprendemos que a priorização das atividades deve ser feita, invariavelmente, pelo time do negócio. Na prática, entretanto, em nosso time,...
× Precisa de ajuda?