The classified website’s identity model extends ASP.NET Core Identity with seller-specific profile data and a role hierarchy that maps to real business needs. Buyers can browse and contact sellers. Sellers can create and manage listings. Moderators can review and remove listings. Admins manage the entire platform. Clean Architecture keeps the identity operations in the Application layer (as commands) with the actual ASP.NET Core Identity calls in the Infrastructure layer — testable and swappable.
Extended Identity and JWT Claims
// ── Infrastructure/Identity/ApplicationUser.cs ────────────────────────────
public class ApplicationUser : IdentityUser
{
public string DisplayName { get; set; } = default!;
public string? AvatarUrl { get; set; }
public bool IsVerifiedSeller { get; set; }
public decimal SellerRating { get; set; }
public int SellerRatingCount { get; set; }
public DateTime MemberSince { get; set; } = DateTime.UtcNow;
// Navigation
public ICollection<Listing> Listings { get; set; } = [];
}
// ── Application/Common/Interfaces/IUserService.cs ─────────────────────────
// Interface in Application; implemented in Infrastructure
public interface IUserService
{
Task<string> RegisterAsync(RegisterUserDto dto, CancellationToken ct);
Task<AuthTokensDto?> LoginAsync(string email, string password, CancellationToken ct);
Task AssignRoleAsync(string userId, string role, CancellationToken ct);
Task<bool> IsInRoleAsync(string userId, string role, CancellationToken ct);
Task VerifySellerAsync(string userId, CancellationToken ct);
}
// ── Infrastructure/Identity/UserService.cs — implements IUserService ──────
public class UserService : IUserService
{
private readonly UserManager<ApplicationUser> _users;
private readonly SignInManager<ApplicationUser> _signIn;
private readonly ITokenService _tokens;
public async Task<AuthTokensDto?> LoginAsync(
string email, string password, CancellationToken ct)
{
var user = await _users.FindByEmailAsync(email);
if (user is null) return null;
var result = await _signIn.CheckPasswordSignInAsync(
user, password, lockoutOnFailure: true);
if (!result.Succeeded) return null;
var roles = await _users.GetRolesAsync(user);
var claims = BuildCustomClaims(user, roles);
return await _tokens.GenerateTokensAsync(user, roles, claims, ct);
}
private static IEnumerable<Claim> BuildCustomClaims(
ApplicationUser user, IList<string> roles)
{
// Custom claims beyond the standard ones
yield return new Claim("displayName", user.DisplayName);
yield return new Claim("isVerifiedSeller", user.IsVerifiedSeller.ToString().ToLower());
yield return new Claim("sellerRating", user.SellerRating.ToString("F1"));
yield return new Claim("memberSince", user.MemberSince.ToString("yyyy-MM-dd"));
if (user.AvatarUrl is not null)
yield return new Claim("avatarUrl", user.AvatarUrl);
}
public async Task VerifySellerAsync(string userId, CancellationToken ct)
{
var user = await _users.FindByIdAsync(userId)
?? throw new NotFoundException($"User {userId} not found.");
user.IsVerifiedSeller = true;
await _users.UpdateAsync(user);
// The user must re-login to get the updated isVerifiedSeller claim in their JWT
}
}
// ── Program.cs — authorisation policies ──────────────────────────────────
builder.Services.AddAuthorization(opts =>
{
opts.AddPolicy("RequireSeller",
p => p.RequireAuthenticatedUser().RequireRole("Seller", "Moderator", "Admin"));
opts.AddPolicy("RequireVerifiedSeller",
p => p.RequireAuthenticatedUser()
.RequireRole("Seller", "Admin")
.RequireClaim("isVerifiedSeller", "true"));
opts.AddPolicy("RequireModerator",
p => p.RequireAuthenticatedUser().RequireRole("Moderator", "Admin"));
opts.AddPolicy("RequireAdmin",
p => p.RequireAuthenticatedUser().RequireRole("Admin"));
});
IUserService interface in the Application layer abstracts all ASP.NET Core Identity operations — this is the Clean Architecture boundary between Application and Infrastructure. Application layer handlers inject IUserService and call methods like LoginAsync(), knowing nothing about UserManager<T> or SignInManager<T>. This makes the Application layer fully testable by mocking IUserService, without needing Identity’s database or password hashing infrastructure.isVerifiedSeller, sellerRating) in the JWT at login time so they are available to every request without a database lookup. The trade-off: claims become stale if the user’s seller status changes while their token is valid. Mitigate this with short access token expiry (15 minutes) — after expiry, the refresh token flow issues a new access token with fresh claims. For immediate revocation (e.g., seller account suspended), maintain a token blacklist or use short expiry plus instant logout.RequireVerifiedSeller policy checks the isVerifiedSeller claim from the JWT. When an admin verifies a seller via VerifySellerAsync(), the database is updated but the user’s current JWT still has isVerifiedSeller: false until they log out and back in (or their access token expires and is refreshed). Communicate this to users: “Your verified seller status will take effect on your next login.” This is a known trade-off of stateless JWT authentication — there is no server-side session to update.Common Mistakes
Mistake 1 — Calling UserManager directly in Application layer handlers (violates Clean Architecture)
❌ Wrong — UserManager<ApplicationUser> injected into a command handler in Application layer; handler now depends on ASP.NET Core Identity.
✅ Correct — inject IUserService in Application layer; UserService (in Infrastructure) wraps UserManager.
Mistake 2 — Stale claims after seller verification (policy check still fails)
❌ Wrong — seller verified in DB; JWT still has old claim; RequireVerifiedSeller policy rejects them; no feedback to user.
✅ Correct — inform user to re-login after verification; or force token refresh; document the claim-staleness behaviour.