Customizando perfis do ASP.NET Identity

O ASP.NET Identity é uma API que fornece os recursos necessários para implementar autenticação e autorização em aplicações .NET. Neste artigo, você vai aprender a estender as tabelas padrão dele para guardar dados extras e usar essas informações no login.

O exemplo prático adiciona datas de início e de término à relação entre usuários e roles. Com isso, um perfil de acesso passa a valer apenas dentro de um período definido, algo comum em assinaturas, cursos e acessos temporários.

O que o ASP.NET Identity oferece

O Identity oferece gerenciamento de usuários, senhas, dados de perfil, roles, claims, tokens e até confirmação de e-mail, entre outros recursos. Além disso, ele possibilita a autenticação com dados do banco e com provedores externos, como Google e Microsoft Account.

A documentação oficial de personalização do modelo do Identity descreve as entidades que formam esse modelo. Entre elas estão o usuário, a role, o claim do usuário, o login externo, o token e a relação entre usuário e role.

Se você quer revisar a diferença entre os dois conceitos centrais, o artigo o que é autenticação e autorização é um bom ponto de partida.

Por que customizar o ASP.NET Identity

No entanto, ao desenvolver sistemas, muitas vezes você precisa de dados que não existem nas tabelas padrão. Para contornar essa limitação, o ASP.NET Identity permite estender as classes do modelo e, assim, adicionar colunas novas.

Você faz essa personalização com herança e com os parâmetros genéricos do IdentityDbContext. Portanto, você não precisa reescrever o Identity, apenas informar quais classes ele deve usar no lugar das versões padrão.

A mesma técnica vale para outras entidades do ASP.NET Identity. Por exemplo, você pode adicionar o nome completo ao usuário, uma descrição à role ou metadados aos claims.

Neste artigo, o foco fica na relação entre usuário e role, porque é nela que mora a regra de vigência. Mesmo assim, os passos de herança, contexto e migration se repetem para qualquer outra customização do ASP.NET Identity.

Contexto do exemplo

O sistema deste artigo precisa autenticar usuários com base em suas roles. Contudo, essas roles só valem dentro de um período, ou seja, precisam dos campos StartDate (data de início) e EndDate (data de término).

Por padrão, a tabela AspNetUserRoles armazena apenas UserId e RoleId. Então, será necessário estender essa tabela para incluir os campos de data.

Os exemplos a seguir partem de um projeto Blazor já configurado com o Identity e usam o banco de dados SQL Server. Você pode criar o projeto com o comando abaixo.

dotnet new blazor -o IdentityBlazor --auth Individual

A opção --auth Individual gera as páginas de login, registro e gerenciamento de conta. Além disso, ela cria a classe ApplicationUser e o ApplicationDbContext, que você vai alterar nas próximas etapas.

Estendendo a classe IdentityUserRole do ASP.NET Identity

Para incluir os campos StartDate e EndDate, o primeiro passo é criar uma classe que herde de IdentityUserRole. Neste exemplo, o projeto usa Guid em vez de string como identificador.

public class ApplicationUserRole : IdentityUserRole<Guid>
{
    // Período em que a role é válida para o usuário
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
}

Como o identificador passa a ser Guid, a classe ApplicationUser também precisa herdar de IdentityUser<Guid>. Do mesmo modo, as roles passam a usar a classe IdentityRole<Guid>.

public class ApplicationUser : IdentityUser<Guid>
{
}

Repare que as datas usam DateTime e que a consulta vai compará-las com DateTime.UtcNow. Por isso, grave os valores sempre em UTC para evitar diferenças de fuso horário.

Atualizando o ApplicationDbContext do ASP.NET Identity

Depois de definir a classe customizada, informe ao contexto que ele deve usar ApplicationUserRole no lugar da versão padrão. Para isso, altere os parâmetros genéricos do IdentityDbContext e adicione uma propriedade de acesso à nova entidade.

public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
    : IdentityDbContext<
        ApplicationUser,
        IdentityRole<Guid>,
        Guid,
        IdentityUserClaim<Guid>,
        ApplicationUserRole, // no lugar do IdentityUserRole<Guid>
        IdentityUserLogin<Guid>,
        IdentityRoleClaim<Guid>,
        IdentityUserToken<Guid>>(options)
{
    // Propriedade de acesso à tabela de relação entre usuário e role
    public DbSet<ApplicationUserRole> ApplicationUserRoles { get; set; }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        builder.Entity<ApplicationUserRole>(entity =>
        {
            entity.HasKey(ur => new { ur.UserId, ur.RoleId });
        });
    }
}

A sobrecarga com oito parâmetros genéricos é a que permite trocar cada entidade do modelo. Assim, o ASP.NET Identity continua cuidando das demais tabelas, enquanto a relação entre usuário e role ganha os novos campos.

O próprio IdentityDbContext já expõe a propriedade UserRoles com o tipo informado. Mesmo assim, a propriedade ApplicationUserRoles deixa explícito, no código, que essa tabela foi customizada.

Note também que a configuração da chave vale para a classe ApplicationUserRole. Configurar a classe base IdentityUserRole<Guid> diretamente poderia levar o EF Core a mapear uma hierarquia de herança, o que não é o objetivo aqui.

Registrando o ASP.NET Identity com Guid

Com as classes prontas, ajuste a configuração do Identity no Program.cs para usar Guid como base. No artigo original, o autor usou o método AddIdentity, como mostra o trecho a seguir.

builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
        options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddRoleManager<RoleManager<IdentityRole<Guid>>>()
    .AddSignInManager<SignInManager<ApplicationUser>>()
    .AddDefaultTokenProviders();

O template Blazor, por outro lado, já registra os cookies com AddIdentityCookies e usa o AddIdentityCore. Nesse caso, basta incluir o suporte a roles no registro existente, sem duplicar os esquemas de autenticação.

builder.Services.AddIdentityCore<ApplicationUser>(options =>
        options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole<Guid>>() // habilita o RoleManager
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddSignInManager()
    .AddDefaultTokenProviders();

Escolha apenas uma das duas formas no mesmo projeto. Afinal, ambas registram os serviços do Identity, e a segunda é a que combina com o código gerado pelo template.

Atualizando o banco de dados

Após as modificações no código, crie uma migration e atualize o banco de dados para refletir as alterações. O primeiro passo é configurar o projeto para usar o SQL Server, instalando o pacote do provedor.

dotnet add package Microsoft.EntityFrameworkCore.SqlServer

Em seguida, no arquivo Program.cs, atualize o registro do contexto para usar o SQL Server.

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
    ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));

Depois, altere o arquivo appsettings.json para adicionar a conexão com o SQL Server. Em produção, prefira guardar essa string em User Secrets ou em um cofre de segredos.

"ConnectionStrings": {
  "DefaultConnection": "Server=localhost,1433;Database=test-identity;User ID=sa;Password=Your_password123;Trusted_Connection=False;TrustServerCertificate=True;"
}

Removendo as migrations antigas

Para garantir que todas as mudanças cheguem ao banco, remova as migrations geradas pelo template. Isso é necessário porque o template as gerou para o SQLite, que usa TEXT como tipo da chave primária.

Se você executar os comandos a seguir sem remover as migrations antigas, ou sem ajustá-las para uniqueidentifier, erros podem ocorrer na criação da migration.

dotnet ef migrations add UpdateIdentityUserRole
dotnet ef database update

Esses comandos geram a migration com os novos campos e aplicam a atualização no banco. Ao final, a tabela AspNetUserRoles passa a ter as colunas StartDate e EndDate.

Atualizando o login do ASP.NET Identity

Agora é necessário alterar o fluxo de login para considerar os novos campos. O objetivo é verificar se cada role do usuário ainda está dentro do período de validade no momento da autenticação.

Criação do ApplicationSignInManager

Para isso, crie uma classe chamada ApplicationSignInManager, que herda de SignInManager<ApplicationUser>. Essa abordagem permite sobrescrever o método de login do ASP.NET Identity sem alterar as páginas geradas pelo template.

A classe precisa receber todas as dependências da classe base e repassá-las ao construtor dela. Além disso, injete o contexto do banco e o RoleManager, que participam da validação das datas.

public class ApplicationSignInManager : SignInManager<ApplicationUser>
{
    private readonly ApplicationDbContext _context;
    private readonly RoleManager<IdentityRole<Guid>> _roleManager;

    public ApplicationSignInManager(
        UserManager<ApplicationUser> userManager,
        IHttpContextAccessor contextAccessor,
        IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory,
        IOptions<IdentityOptions> optionsAccessor,
        ILogger<SignInManager<ApplicationUser>> logger,
        IAuthenticationSchemeProvider schemes,
        IUserConfirmation<ApplicationUser> confirmation,
        ApplicationDbContext context,
        RoleManager<IdentityRole<Guid>> roleManager)
        : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation)
    {
        _context = context;
        _roleManager = roleManager;
    }

    public override async Task<SignInResult> PasswordSignInAsync(
        string userName, string password, bool isPersistent, bool lockoutOnFailure)
    {
        // A implementação do login vem a seguir
    }
}

A classe base já expõe as propriedades UserManager e Context. Por isso, não é preciso guardar uma cópia própria do UserManager em um campo privado.

Validando usuário e senha no ASP.NET Identity

Com as dependências configuradas, o fluxo de login começa pela busca do usuário a partir do nome informado. Em seguida, o código valida a senha.

var user = await UserManager.FindByNameAsync(userName);
if (user is null)
    return SignInResult.Failed;

// Valida a senha respeitando bloqueio e confirmação de conta
var check = await CheckPasswordSignInAsync(user, password, lockoutOnFailure);
if (!check.Succeeded)
    return check;

No exemplo original, o código conferia a senha com UserManager.CheckPasswordAsync. Nesta versão, o método CheckPasswordSignInAsync da própria classe base faz a mesma conferência e também respeita o bloqueio por tentativas e a exigência de conta confirmada.

Consultando as roles válidas no ASP.NET Identity

Se a senha estiver correta, o próximo passo é descobrir quais roles do usuário continuam válidas. Para isso, consulte a tabela ApplicationUserRoles e filtre os registros ativos.

var now = DateTime.UtcNow;

// Roles ativas: começaram até agora e ainda não terminaram
var userRoles = await _context.ApplicationUserRoles
    .Where(ur => ur.UserId == user.Id &&
                 ur.StartDate <= now &&
                 ur.EndDate > now)
    .Select(ur => ur.RoleId)
    .ToListAsync();

Uma role fica ativa quando a StartDate é menor ou igual à data atual e a EndDate é posterior a ela. Guardar a data em uma variável garante que as duas comparações usem exatamente o mesmo instante.

Gerando os claims e autenticando o usuário

Com a lista de roles válidas em mãos, gere os claims essenciais do usuário. Neste caso, são o nome de usuário e o identificador.

var claims = new List<Claim>
{
    new(ClaimTypes.Name, user.UserName!),
    new(ClaimTypes.NameIdentifier, user.Id.ToString()),
    // Mantém o security stamp usado pelo Identity na revalidação do cookie
    new(Options.ClaimsIdentity.SecurityStampClaimType, await UserManager.GetSecurityStampAsync(user))
};

O claim de security stamp não estava no exemplo original. Ele é recomendado porque o Identity compara esse valor periodicamente para invalidar sessões quando a senha ou as permissões mudam.

Caso existam roles válidas, percorra a lista e adicione o claim de role correspondente.

foreach (var roleId in userRoles)
{
    var role = await _roleManager.FindByIdAsync(roleId.ToString());
    if (role is not null)
        claims.Add(new Claim(ClaimTypes.Role, role.Name!));
}

Por fim, os claims formam uma ClaimsIdentity, que por sua vez gera um ClaimsPrincipal. Com esse principal, o método SignInAsync do contexto HTTP autentica o usuário.

var identity = new ClaimsIdentity(claims, IdentityConstants.ApplicationScheme);
var claimsPrincipal = new ClaimsPrincipal(identity);

await Context.SignInAsync(IdentityConstants.ApplicationScheme, claimsPrincipal, new AuthenticationProperties
{
    IsPersistent = isPersistent
});

return SignInResult.Success;

A constante IdentityConstants.ApplicationScheme vale "Identity.Application", o mesmo texto usado no exemplo original. Usar a constante evita erros de digitação no nome do esquema.

Se quiser se aprofundar na criação e na leitura desses valores, o artigo sobre claims customizados no ASP.NET mostra outras formas de trabalhar com eles.

Registrando o SignInManager customizado

Para que a implementação customizada seja usada, registre a classe no container de injeção de dependências. A forma mais direta é trocar a chamada AddSignInManager no registro do ASP.NET Identity.

builder.Services.AddIdentityCore<ApplicationUser>(options =>
        options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole<Guid>>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddSignInManager<ApplicationSignInManager>() // versão customizada
    .AddDefaultTokenProviders();

O original registrava o serviço com AddScoped<SignInManager<IdentityUser<Guid>>, ApplicationSignInManager>(). Contudo, o tipo correto do serviço é SignInManager<ApplicationUser>, pois é ele que as páginas do template injetam.

Com isso, a aplicação passa a resolver o ApplicationSignInManager sempre que alguém pedir um SignInManager<ApplicationUser>. Dessa forma, o fluxo de autenticação considera os campos StartDate e EndDate das roles.

Testando a implementação

Atribuindo uma role com período no ASP.NET Identity

O método UserManager.AddToRoleAsync não conhece os novos campos. Por isso, para gravar uma role com período, crie a entidade ApplicationUserRole diretamente pelo contexto.

var role = await roleManager.FindByNameAsync("premium");

// Concede a role por 30 dias a partir de agora
context.ApplicationUserRoles.Add(new ApplicationUserRole
{
    UserId = user.Id,
    RoleId = role!.Id,
    StartDate = DateTime.UtcNow,
    EndDate = DateTime.UtcNow.AddDays(30)
});

await context.SaveChangesAsync();

Lembre que a chave da tabela é composta por UserId e RoleId. Então, para renovar um período, atualize o registro existente em vez de inserir um novo, ou o banco vai rejeitar a chave duplicada.

Esse mesmo código pode virar um serviço da aplicação, chamado após a confirmação de um pagamento ou a matrícula em um curso. Dessa forma, a regra de vigência fica concentrada em um único lugar.

Validando o comportamento na interface

Devido ao tamanho do código, o exemplo completo dos testes está no GitHub, com o link ao final do artigo. A seguir, está o resumo do que ele valida.

Agora que as roles possuem os campos StartDate e EndDate, é importante verificar o comportamento na prática. Para isso, a página de teste permite alterar a role do usuário autenticado e conferir se a lógica de datas funciona.

A interface tem dois botões. O primeiro atribui uma role válida ao usuário, enquanto o segundo atribui uma role com período vencido.

Depois de cada alteração, faça logout e login novamente. Assim, você confirma que apenas a role dentro do período aparece nos claims do usuário autenticado.

Limitações da validação no login

A validação acontece apenas no momento do login. Portanto, se uma role expirar enquanto o usuário estiver autenticado, ela continua no cookie até a próxima autenticação.

Se o seu cenário exige corte imediato, combine essa abordagem com uma revalidação periódica. Uma opção é atualizar o security stamp do usuário quando a role vencer, o que força o Identity a invalidar a sessão.

Conclusão

Neste artigo, você viu como personalizar e estender o ASP.NET Identity para atender necessidades específicas de autenticação e autorização. Primeiro, a tabela de relação entre usuários e roles ganhou os campos StartDate e EndDate, que definem o período de vigência de cada perfil.

Em seguida, o ApplicationSignInManager sobrescreveu o método PasswordSignInAsync para aplicar essa validação durante o login. Além disso, alguns ajustes deixaram o fluxo mais seguro, como o respeito ao bloqueio de conta e a inclusão do security stamp.

Com essas customizações, o ASP.NET Identity se adapta a cenários mais complexos. Afinal, o acesso passa a depender não apenas das credenciais, mas também da validade temporal das permissões.

Essa abordagem traz mais flexibilidade e segurança para a aplicação. Também mostra que é possível ir além dos recursos padrão do ASP.NET Identity para atender regras específicas do negócio.

Repositório do projeto

Por se tratar de um exemplo extenso, o artigo omite algumas partes do código para facilitar a leitura. O repositório completo está disponível no GitHub e é acessível a todos.

FAQ

É possível adicionar colunas às tabelas do ASP.NET Identity?

Sim, basta criar uma classe que herde da entidade padrão e informá-la nos parâmetros genéricos do IdentityDbContext. Depois, gere uma migration para aplicar as colunas no banco.

Por que usar Guid em vez de string como chave?

O Guid ocupa menos espaço que uma string e mapeia para o tipo uniqueidentifier do SQL Server. A escolha é opcional, mas precisa ser consistente em todas as entidades.

Preciso remover as migrations do template?

Sim, quando trocar o SQLite pelo SQL Server ou mudar o tipo da chave. As migrations antigas usam TEXT como chave e geram conflito com as novas.

Como registrar um SignInManager customizado?

Use o método AddSignInManager informando a sua classe no registro do Identity. Assim, toda injeção de SignInManager do usuário passa a receber a versão customizada.

A role expira automaticamente para quem já está logado?

Não, a validação ocorre apenas no login. Para corte imediato, atualize o security stamp do usuário ou implemente uma revalidação periódica.