KopyKay
KopyKay

Reputation: 11

ASP.NET Core Identity keeps returning 401 Unauthorized on failed login attempts with a user who has custom claims

I am using Microsoft.AspNetCore.Identity.EntityFrameworkCore and leveraging the endpoints available in this library (login and register). In a standard scenario, if a user provides an incorrect password during login (right after register), the user can retry and obtain a bearer access/refresh token upon successful authentication.

In my project, all endpoints (except those available in the package and my user update endpoint) require authorization based on the HasProfileCompleted policy, which verifies the claim IsProfileCompleted = True. This means that after logging in, the user must update account via the api/users/update endpoint by providing the required data (first name, last name, username, phone number). If the data is correctly provided and the IsProfileCompleted value is set to true, the user gains full access to the API.

After updating the profile, I refresh the token, which allows requests to be processed correctly. However, a problem arises when a user enters the correct email address but an incorrect password during login — subsequent login attempts result in a 401 Unauthorized error, even though the account is not subject to the lockout mechanism (I increased MaxFailedAccessAttempts and set RequireConfirmedAccount to false in AddIdentityApiEndpoints options etc. and still nothing...)

What could be causing this issue? Did I overlook something? What steps should I take to handle this scenario correctly?

Program.cs:

using Serilog;
using ZlecajGo.API.Extensions;
using ZlecajGo.API.Middlewares;
using ZlecajGo.Application.Extensions;
using ZlecajGo.Domain.Entities;
using ZlecajGo.Infrastructure.Extensions;
using ZlecajGo.Infrastructure.Seeders;

var builder = WebApplication.CreateBuilder(args);

builder.AddPresentation();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

using var scope = app.Services.CreateScope();
var seeder = scope.ServiceProvider.GetRequiredService<IZlecajGoSeeder>();
await seeder.SeedAsync(false);

app.UseSerilogRequestLogging();

app.UseMiddleware<ErrorHandlingMiddleware>();

app.UseHttpsRedirection();

app.MapGroup("/api/identity")
    .WithTags("Identity")
    .MapIdentityApi<User>();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

Log.Information("API started");

app.Run();

AddPresentation method:

public static void AddPresentation(this WebApplicationBuilder builder)
{
    builder.Services.AddAuthentication();
    
    builder.Services.AddControllers();
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen(c =>
    {
        c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "Bearer"
        });
        
        c.AddSecurityRequirement(new OpenApiSecurityRequirement
        {
            {
                new OpenApiSecurityScheme
                {
                    Reference = new OpenApiReference
                    {
                        Type = ReferenceType.SecurityScheme,
                        Id = "bearerAuth"
                    }
                },
                []
            }
        });
    });
    
    builder.Host.UseSerilog((context, logger) =>
    {
        logger.ReadFrom.Configuration(context.Configuration);
    });
    
    builder.Services.AddScoped<ErrorHandlingMiddleware>();
}

AddInfrastructure method:

public static void AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
    var connectionString = configuration.GetConnectionString("ZlecajGoDb");

    services.AddDbContext<ZlecajGoContext>(options =>
        options.UseNpgsql(connectionString)
            .EnableSensitiveDataLogging());
    
    services.AddIdentityApiEndpoints<User>()
        .AddRoles<IdentityRole>()
        .AddClaimsPrincipalFactory<ZlecajGoUserClaimsPrincipalFactory>()
        .AddEntityFrameworkStores<ZlecajGoContext>();
    
    services.AddAuthorizationBuilder()
        .AddPolicy(PolicyNames.HasProfileCompleted, builder => 
            builder.RequireClaim(AppClaimTypes.IsProfileCompleted, "True"));
    
    services.AddScoped<IZlecajGoSeeder, ZlecajGoSeeder>();
    
    services.AddScoped<ICategoryRepository, CategoryRepository>();
    services.AddScoped<IOfferContractorRepository, OfferContractorRepository>();
    services.AddScoped<IOfferRepository, OfferRepository>();
    services.AddScoped<IReviewRepository, ReviewRepository>();
    services.AddScoped<IStatusRepository, StatusRepository>();
    services.AddScoped<ITypeRepository, TypeRepository>();
}

My principal factory ZlecajGoUserClaimsPrincipalFactory.cs:

using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using ZlecajGo.Domain.Constants;
using ZlecajGo.Domain.Entities;

namespace ZlecajGo.Infrastructure.Authorization;

public class ZlecajGoUserClaimsPrincipalFactory
(
    UserManager<User> userManager,
    RoleManager<IdentityRole> roleManager,
    IOptions<IdentityOptions> options
)
: UserClaimsPrincipalFactory<User, IdentityRole>(userManager, roleManager, options)
{
    public override async Task<ClaimsPrincipal> CreateAsync(User user)
    {
        var id = await GenerateClaimsAsync(user);
        
        if (user.UserName != null)
            id.AddClaim(new Claim(AppClaimTypes.UserName, user.UserName));
        
        if (user.FullName != null)
            id.AddClaim(new Claim(AppClaimTypes.FullName, user.FullName));
        
        if (user.PhoneNumber != null)
            id.AddClaim(new Claim(AppClaimTypes.PhoneNumber, user.PhoneNumber));
        
        if (user.BirthDate != null)
            id.AddClaim(new Claim(AppClaimTypes.BirthDate, user.BirthDate.Value.ToString("dd-MM-yyyy")));
        
        id.AddClaim(new Claim(AppClaimTypes.IsProfileCompleted, user.IsProfileCompleted.ToString()));

        return new ClaimsPrincipal(id);
    }
}

Update user endpoint handler UpdateUserCommandHandler.cs:

using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using ZlecajGo.Domain.Entities;
using ZlecajGo.Domain.Exceptions;

namespace ZlecajGo.Application.Users.Commands.UpdateUser;

public class UpdateUserCommandHandler
(
    ILogger<UpdateUserCommandHandler> logger,
    IUserStore<User> userStore,
    IUserContext userContext
)    
: IRequestHandler<UpdateUserCommand>
{
    public async Task Handle(UpdateUserCommand request, CancellationToken cancellationToken)
    {
        var user = userContext.GetCurrentUser();
        
        logger.LogInformation("Updating user with id [{UserId}]", user!.Id);
        
        var dbUser = await userStore.FindByIdAsync(user.Id, cancellationToken)
            ?? throw new NotFoundException(nameof(User), user.Id);
        
        dbUser.FullName = request.FullName ?? dbUser.FullName;
        dbUser.BirthDate = request.BirthDate ?? dbUser.BirthDate;
        dbUser.Email = request.Email ?? dbUser.Email;
        dbUser.UserName = request.UserName ?? dbUser.UserName;
        dbUser.PhoneNumber = request.PhoneNumber ?? dbUser.PhoneNumber;
        dbUser.ProfilePictureUrl = request.ProfilePictureUrl ?? dbUser.ProfilePictureUrl;

        if (dbUser.IsProfileCompleted == false && dbUser is 
            { 
                FullName: not null, 
                BirthDate: not null, 
                Email: not null, 
                UserName: not null, 
                PhoneNumber: not null 
            })
        {
            dbUser.IsProfileCompleted = true;
        }
        
        await userStore.UpdateAsync(dbUser, cancellationToken);
    }
}

UserContext.cs to get current user:

using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using ZlecajGo.Domain.Constants;

namespace ZlecajGo.Application.Users;

public interface IUserContext
{
    CurrentUser? GetCurrentUser();
}

public class UserContext(IHttpContextAccessor httpContextAccessor) : IUserContext
{
    public CurrentUser? GetCurrentUser()
    {
        var user = httpContextAccessor.HttpContext?.User;

        if (user is null)
            throw new InvalidOperationException("User context is not present!");
        
        if (user.Identity is null || !user.Identity.IsAuthenticated)
            return null;
        
        var userId = user.FindFirst(c => c.Type == ClaimTypes.NameIdentifier)!.Value;
        var email = user.FindFirst(c => c.Type == ClaimTypes.Email)!.Value;
        var userName = user.FindFirst(c => c.Type == AppClaimTypes.UserName)!.Value;
        var fullName = user.FindFirst(c => c.Type == AppClaimTypes.FullName)?.Value;
        var phoneNumber = user.FindFirst(c => c.Type == AppClaimTypes.PhoneNumber)?.Value;
        var birthDateString = user.FindFirst(c => c.Type == AppClaimTypes.BirthDate)?.Value;
        var birthDate = birthDateString is null ? (DateOnly?)null : DateOnly.ParseExact(birthDateString, "dd-MM-yyyy");
        var isProfileCompleted = bool.Parse(user.FindFirst(c => c.Type == AppClaimTypes.IsProfileCompleted)!.Value);
        
        return new CurrentUser(userId, email, userName, fullName, phoneNumber, birthDate, isProfileCompleted);
    }
}

Upvotes: 0

Views: 70

Answers (1)

KopyKay
KopyKay

Reputation: 11

So, the reason for my problem is that in the UpdateUserCommandHandler class, I update the user, including their UserName, which in the Identity database is literally a copy of the Email column. Digging into how the login endpoint works (which comes from Identity), I discovered that the login process searches for the user by their UserName rather than their Email: documentation.

This is very misleading when using Swagger because the "example value" suggests entering an email address, which is confusing. By default, if no changes are made to the user, the UserName indeed receives the email address as a copy of Email. However, since I update the UserName, entering the email address during login results in a 401 Unauthorized error. On the other hand, when I use the user's UserName for login, everything works correctly.

Upvotes: 0

Related Questions