Goal
- Demonstrate a production-oriented customization of ASP.NET Core Identity on .NET 8 with MVC controllers (replacing Razor Pages Identity), focusing on localization, SMS-based authentication, admin tooling, and scalable session management.
Features
- .NET 8 minimal hosting with all setup consolidated in
Program.cs
. - MVC controllers in Identity area replacing default Razor Pages Identity.
- Identity with
int
keys: customApplicationUser
,ApplicationRole
, and related entities. - Custom EF Core schema mapping: renamed tables/columns in
ApplicationDbContext
(e.g.,Users
,Roles
,UserID
). - Persian
IdentityErrorDescriber
for localized Identity error messages. - Custom
UserStore
implementation enabling login by username, email, or phone number. - Passwordless SMS login flow using
UserLoginWithSms
andSmsService
. - SMS-based pre-registration (
UserPreRegistration
) gated byIdentity:PreRegistrationEnabled
configuration. - Server-side cookie session storage via
ITicketStore
implementationDatabaseTicketStore
andAuthenticationTicket
model. - Online user session management dashboard (
/UserSessions
) with Bootstrap confirmations and admin actions (force logout, cleanup expired, clear all). - In-memory
MemoryCacheTicketStore
utility for session ticket management (optional). - Hangfire integration with SQL Server storage, dashboard, and recurring job
DatabaseCleanerService
for cleanup. - Admin area to manage users and roles: create users, assign roles, reset passwords.
- Role-based UI visibility with
RolesTagHelper
usingvisible-to-roles
attribute in Razor. ClaimsPrincipal
helpers inIdentityExtensions
for user id/name/email access.- Cookie path configuration and relaxed Identity options (e.g.,
RequireUniqueEmail=false
).
- Set
DefaultConnection
inappsettings.json
to your SQL Server. - Apply migrations (e.g.,
dotnet ef database update
). - Run the app (
dotnet run
) and browse to/Identity/Account/Login
or/Admin
.
This section details all custom features implemented in this project compared to stock ASP.NET Core Identity, with code samples and explanations.
Target: .NET 8, MVC controllers (no Razor Pages Identity)
What: ApplicationUser : IdentityUser<int>
, ApplicationRole : IdentityRole<int>
and custom claim/login/role/token entities. The ApplicationDbContext
changes default table and column names.
Code (ApplicationDbContext):
public class ApplicationDbContext : IdentityDbContext<
ApplicationUser,
ApplicationRole,
int,
ApplicationUserClaim,
ApplicationUserRole,
ApplicationUserLogin,
ApplicationRoleClaim,
ApplicationUserToken>
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ApplicationUser>(b =>
{
b.ToTable("Users");
b.Property(e => e.Id).HasColumnName("UserID");
b.Property(e => e.UserName).HasColumnName("Username");
b.Property(e => e.NormalizedUserName).HasColumnName("NormalizedUsername");
});
modelBuilder.Entity<ApplicationRole>(b =>
{
b.ToTable("Roles");
b.Property(e => e.Id).HasColumnName("RoleID");
b.Property(e => e.Name).HasColumnName("RoleName");
b.Property(e => e.NormalizedName).HasColumnName("RoleNormalizedName");
});
}
}
Effect: compatibility with existing DB schemas; simpler joining with numeric keys.
What: Localized error messages for Identity via a custom IdentityErrorDescriber
.
Code:
public class PersianIdentityErrorDescriber : IdentityErrorDescriber
{
public override IdentityError DuplicateUserName(string userName) => new()
{
Code = nameof(DuplicateUserName),
Description = $"نام کاربری '{userName}' به کاربر دیگری اختصاص یافته است."
};
}
Registration:
services.AddIdentity<ApplicationUser, ApplicationRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders()
.AddErrorDescriber<PersianIdentityErrorDescriber>();
Effect: consistent localized errors across UI and APIs.
What: Custom UserStore.FindByNameAsync
allows locating user by UserName
, PhoneNumber
, or Email
.
Code:
public Task<ApplicationUser> FindByNameAsync(string normalizedUserName, CancellationToken ct)
{
var dbUser = db.Users.FirstOrDefault(u =>
u.UserName == normalizedUserName ||
u.PhoneNumber == normalizedUserName ||
u.Email == normalizedUserName);
return Task.FromResult(dbUser);
}
Effect: users can sign in using any of the three identifiers. Consider normalizing inputs consistently.
What: Issue one-time code to phone, verify, then sign-in.
Entities and flow:
// Model
public class UserLoginWithSms
{
[Key] public int LoginWithSmsID { get; set; }
public string PhoneNumber { get; set; }
public string AuthenticationCode { get; set; }
public string AuthenticationKey { get; set; }
public DateTime ExpireDate { get; set; }
public int UserID { get; set; }
public ApplicationUser User { get; set; }
public void Initialize() { /* set code/key */ }
}
Controller highlights:
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> LoginWithSms(LoginWithSmsModel model)
{
var user = await _userManager.FindByNameAsync(model.PhoneNumber);
var loginWithSms = new UserLoginWithSms
{
PhoneNumber = user.PhoneNumber,
UserID = user.Id,
ExpireDate = DateTime.Now.AddMinutes(5)
};
loginWithSms.Initialize();
db.UserLoginWithSms.Add(loginWithSms);
await db.SaveChangesAsync();
await smsService.SendSms($"کد امنیتی شما: {loginWithSms.AuthenticationCode}", new() { loginWithSms.PhoneNumber });
return RedirectToAction("LoginWithSmsResponse", new { Key = loginWithSms.AuthenticationKey });
}
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> LoginWithSmsResponse(LoginWithSmsResponseModel model)
{
var row = db.UserLoginWithSms.Include(pr => pr.User)
.FirstOrDefault(pr => pr.AuthenticationKey == model.AuthenticationKey);
if (row != null && row.AuthenticationCode == model.AuthenticationCode)
{
await _signInManager.SignInAsync(row.User, true);
return RedirectToAction("Index", "Manage", new { area = "Identity" });
}
// handle errors
}
Effect: passwordless UX; security depends on code entropy, expiry, rate-limiting, and delivery channel.
What: Optional pre-registration step toggled by Identity:PreRegistrationEnabled
. User verifies phone via SMS before registration continues.
Code (controller excerpts):
[AllowAnonymous]
public async Task<IActionResult> PreRegister(UserPreRegistration model)
{
model.Initialize();
model.ExpireTime = DateTime.Now.AddMinutes(5);
db.PreRegistrations.Add(model);
await db.SaveChangesAsync();
await smsService.SendSms($"کد امنیتی: {model.AuthenticationCode}", new() { model.PhoneNumber });
return RedirectToAction("PreRegisterConfirm", new { Key = model.AuthenticationKey });
}
[AllowAnonymous]
public IActionResult Register(string returnUrl = null, string Key = null)
{
bool enabled = Configuration.GetValue<bool>("Identity:PreRegistrationEnabled");
if (enabled)
{
var ok = db.PreRegistrations.Any(pr => pr.Confirmed && pr.AuthenticationKey == Key && pr.ExpireTime > DateTime.Now);
if (!ok) return RedirectToAction("PreRegister");
}
return View();
}
Effect: mitigates fake registrations; ensures phone verification prior to account creation.
What: Areas/Admin
controllers to manage roles and users, including assigning roles and resetting user passwords.
Code (assign roles by IDs):
[HttpPost]
public async Task<IActionResult> Create(CreateUserModel model, List<int> selectedRoles)
{
var user = new ApplicationUser { UserName = model.Username, Email = model.Email };
var result = await userManager.CreateAsync(user, model.Password);
if (result.Succeeded && selectedRoles.Any())
{
var userRoles = selectedRoles.Select(sr => new ApplicationUserRole { UserId = user.Id, RoleId = sr }).ToList();
await context.UserRoles.AddRangeAsync(userRoles);
await context.SaveChangesAsync();
}
// ...
}
Effect: quick management UI; writes directly via EF for roles assignment.
What: ApplicationUser
/ApplicationRole
have Persian Display
attributes; int keys and navigation properties.
Code:
public class ApplicationUser : IdentityUser<int>
{
[Display(Name= "نام کاربر")]
[Required(ErrorMessage = "لطفا {0} را وارد کنید")]
public override string UserName { get; set; }
public virtual ICollection<ApplicationUserRole> UserRoles { get; set; }
// ...
}
Effect: localized UI labels; richer navigation for queries.
What: Replaces default cookie-only storage with DB-backed ITicketStore
(DatabaseTicketStore
) for the Identity cookie.
Updated registration (DI + options):
// Required for DatabaseTicketStore
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddSingleton<DatabaseTicketStore>();
services.AddSingleton<ITicketStore>(sp => sp.GetRequiredService<DatabaseTicketStore>());
services.AddOptions<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme)
.Configure<ITicketStore>((options, store) => { options.SessionStore = store; });
Store (excerpt):
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
var userId = ticket.Principal.FindFirst(ClaimTypes.NameIdentifier).Value;
var authenticationTicket = new Models.Identity.AuthenticationTicket
{
UserId = Convert.ToInt32(userId),
LastActivity = DateTimeOffset.UtcNow,
Value = TicketSerializer.Default.Serialize(ticket),
Expires = ticket.Properties.ExpiresUtc
};
db.AuthenticationTickets.Add(authenticationTicket);
await db.SaveChangesAsync();
return authenticationTicket.UserId.ToString();
}
Effect: centralized session management, easier invalidation across servers, and visibility of active sessions.
Route: /UserSessions
Features:
- View currently online users with activity and session metadata
- Force logout a user (now supports logging out the current signed-in admin as well)
- Cleanup expired sessions
- Clear all sessions (nuclear option)
- Bootstrap modals for all confirmations (no
alert
/confirm
JS) - AJAX for force-logout to avoid menu/submit issues; other actions keep simple post after modal confirm
Notes:
- For self force-logout, the server signs out the current session and redirects to login
- Anti-forgery tokens and
X-Requested-With
header are used in AJAX posts
Registration and dashboard:
services.AddHangfire(hf => hf
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(configuration.GetConnectionString("DefaultConnection")));
services.AddHangfireServer();
app.MapHangfireDashboard();
Recurring job:
RecurringJob.AddOrUpdate<IDatabaseCleanerService>(
"CleanDatabaseJob",
service => service.CleanDatabaseAsync(),
"*/20 * * * * *");
Helpers to get logged-in user id/name/email from claims.
services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/Identity/Account/Login";
options.LogoutPath = "/Identity/Account/Logout";
options.AccessDeniedPath = "/Identity/Account/AccessDenied";
});
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.SignIn.RequireConfirmedAccount = false;
options.User.RequireUniqueEmail = false;
});
app.MapControllerRoute(name: "areas", pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapHangfireDashboard();
- Added
DatabaseSeeder.SeedRolesAsync(app.Services)
at startup to ensure base roles exist. - In views, replaced all
confirm()
prompts with Bootstrap modals for consistent UX. - Force-logout uses AJAX with proper anti-forgery and shows Bootstrap confirmation before posting.
- Normalize inputs consistently for multi-identifier login; consider enforcing unique phone/email if required.
- Add throttling/rate-limits and audit logs for SMS flows.
- Secure Hangfire dashboard (e.g., authorization filter).
- Periodically purge
AuthenticationTickets
to avoid DB growth. - Replace placeholder email/SMS with production providers.
Follow these steps to implement these Identity customizations in a new ASP.NET Core 8 project by copying and adapting files from this repository.
// DbContext
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
services.AddDatabaseDeveloperPageExceptionFilter();
// Identity
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.SignIn.RequireConfirmedAccount = false;
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders()
.AddErrorDescriber<PersianIdentityErrorDescriber>();
// Session store and cookie options
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddSingleton<DatabaseTicketStore>();
services.AddSingleton<ITicketStore>(sp => sp.GetRequiredService<DatabaseTicketStore>());
services.AddOptions<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme)
.Configure<ITicketStore>((options, store) => { options.SessionStore = store; });
// Services
services.AddTransient<IUserStore<ApplicationUser>, UserStore>();
services.AddTransient<IEmailSender, EmailSender>();
services.AddScoped<ISmsService, SmsService>();
services.AddScoped<IDatabaseCleanerService, DatabaseCleanerService>();
// Hangfire
services.AddHangfire(hf => hf
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(configuration.GetConnectionString("DefaultConnection")));
services.AddHangfireServer();
// Controllers and endpoints
services.AddControllersWithViews();
var app = builder.Build();
// ... middleware ...
app.MapControllerRoute(name: "areas", pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapHangfireDashboard();
// Recurring job
RecurringJob.AddOrUpdate<IDatabaseCleanerService>(
"CleanDatabaseJob",
service => service.CleanDatabaseAsync(),
"*/20 * * * * *");
// Seed roles
await DatabaseSeeder.SeedRolesAsync(app.Services);