Reputation: 11
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
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