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 Scratch using RavenDB.

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 a dedicated .net standard library Project to accommodate your implementation. That is not mandatory, but it will help you to maintain some code organization and reuse.

To implement your 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 that I will install the RavenDB.Client NuGet package as well.

Supporting basic user data management

The most essential 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 exciting. The developer is free to set the user object in the way he wants. The underlying 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 it 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 generate, 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 speedy! 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 determine 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 underlying 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 define other helper methods to get and set data from the user object.

 

Roles of data management

A right 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.

 

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!

How to use 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.

That is all folks.

Compartilhe este insight:

Comentários

Participe deixando seu comentário sobre este artigo a seguir:

Subscribe
Notify of
guest
4 Comentários
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Trav
Trav
4 anos atrás

Excellent article however RavenDB documents don’t have etags any longer but they use change vectors instead the method GetEtagFor. It has been replaced by GetChangeVectorFor.

Elemar Júnior
Elemar Júnior
4 anos atrás

Thanks. This article was written using RavenDB 3.5

Brian
Brian
3 anos atrás

Great article! If you have any additional advice on combining this custom storage provider approach with also using standard Azure/Google/Msft authentication, I’d appreciate it. I can find advice and articles on one or the other, but no discussion on how to combine both.

Arão Benjamin
Arão Benjamin
1 ano atrás

Hi Elemar, great article!
Just tell me something, in the Set… methods, shouldn’t we UpdateAsync the changed user?

AUTOR

Elemar Júnior
Fundador e CEO da EximiaCo atua como tech trusted advisor ajudando empresas e profissionais a gerar mais resultados através da tecnologia.

NOVOS HORIZONTES PARA O SEU NEGÓCIO

Nosso time está preparado para superar junto com você grandes desafios tecnológicos.

Entre em contato e vamos juntos utilizar a tecnologia do jeito certo para gerar mais resultados.

Insights EximiaCo

Confira os conteúdos de negócios e tecnologia desenvolvidos pelos nossos consultores:

Arquivo

Pós-pandemia, trabalho remoto e a retenção dos profissionais de TI

CTO Consulting e Especialista em Execução em TI
EximiaCo 2024 - Todos os direitos reservados
4
0
Queremos saber a sua opinião, deixe seu comentáriox
()
x
Oferta de pré-venda!

Mentoria em
Arquitetura de Software

Práticas, padrões & técnicas para Arquitetura de Software, de maneira efetiva, com base em cenários reais para profissionais envolvidos no projeto e implantação de software.

Muito obrigado!

Deu tudo certo com seu envio!
Logo entraremos em contato

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

Para se candidatar nesta turma aberta, preencha o formulário a seguir:

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

Para se candidatar nesta turma aberta, preencha o formulário a seguir:

Condição especial de pré-venda: R$ 14.000,00 - contratando a mentoria até até 31/01/2023 e R$ 15.000,00 - contratando a mentoria a partir de 01/02/2023, em até 12x com taxas.

Tenho interesse nessa capacitação

Para solicitar mais informações sobre essa capacitação para a sua empresa, preencha o formulário a seguir:

Tenho interesse em conversar

Se você está querendo gerar resultados através da tecnologia, preencha este formulário que um de nossos consultores entrará em contato com você:

O seu insight foi excluído com sucesso!

O seu insight foi excluído e não está mais disponível.

O seu insight foi salvo com sucesso!

Ele está na fila de espera, aguardando ser revisado para ter sua publicação programada.

Tenho interesse em conversar

Se você está querendo gerar resultados através da tecnologia, preencha este formulário que um de nossos consultores entrará em contato com você:

Tenho interesse nessa solução

Se você está procurando este tipo de solução para o seu negócio, preencha este formulário que um de nossos consultores entrará em contato com você:

Tenho interesse neste serviço

Se você está procurando este tipo de solução para o seu negócio, preencha este formulário que um de nossos consultores entrará em contato com você:

× Precisa de ajuda?