Master ASP.NET Core Identity in .NET10: ASP.NET Core Identity
Tutor: Frank Liu
The Three Essential Parts of Identity
ASP.Net Core identity is a membership systems that help us do what we did manually in the previous section.
flowchart LR
usr[User]
bsr[Browser]
subgraph Abstraction["Web Server"]
Vc["Verify Credentials"]
Sc["Generate Security Context"]
Au[Authentication]
Az[Authorization]
end
Db[("Data Store")]
usr -- 1 -->bsr
bsr -- 2 --> Vc
Vc -- 3 --> Db
Db -- 4 --> Sc
Sc -- 5 --> bsr
bsr -- 6--> Au
Au -- 7 --> Az
Az -- 8 --> bsr
These are the three essential parts of ASP.Net Core identity:
- UI
- Browser
- Functionalities
- Everything in the Web Server
- Data Store
- Where User information and claims are stored
We need to add a new project to demonstrate this.
Note: Blazor authentication can be different to traditional web applications.
Add to the solution the following project based on the ASP.NET Core Web App (Razor Pages) template:
- Project Name: WebApp
- Framework: .Net 10
- Authentication Type: None
Create with the rest as defaults.
We will need to install the following Nuget packages:
- Microsoft.AspNetCore.Identity.UI for the UI
- ~~Microsoft.AspNetCore.Identity~~ for the functionalities (not needed as already included)
- Microsoft.AspNetCore.Identity.EntityFrameworkCore for the Data Store
We will also need
- Microsoft.EntityFrameworkCore.SqlServer to write to the data store
- Microsoft.EntityFrameworkCore.SqlServer.Design
- Microsoft.EntityFrameworkCore.SqlServer.Tools
Microsoft.AspNetCore.Identity.UI contains all the scaffolded user interfaces, which are comprehensive and cover most scenarios. This section will not use them and create everything from scratch. This would be used in the real world.
We start by creating our in-memory representation of our identity database, then we will run our migration to actually create the identity database in SQL server.
Create the root folder /Data in the application and add to this a class to represent the in-memory representation of our identity database called ApplicationDbContext.
This class derives from IdentityDbContext which already contains all the Db tables we will need, for example IdentityUser and IdentityRole. The work we need to do just adds to this:
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Data;
public class ApplicationDbContext : IdentityDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options): base(options)
{
}
}
This is going to contain all the database tables that represents a core identity.
Now we need to set up DI Injection and configure the connection to an actual database in the WebApp Program.cs file before builder.Build() is called:
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
Also setup the connection string in appsettings.development.json (or the main file):
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Initial Catalog=UdemySecurityCourse;Integrated Security=True;Trusted_Connection=True;TrustServerCertificate=True;"
}
NOTE: You can add a connection via Visual Studio's Server Explorer pane and copy the details from there if it helps.

We can now run database migration to actually create the database. Set WebAPI as the startup project, then in Package Manager Console with the default project set as WebApp:
> Add-Migration Initial
> Update-Database
Configure WebApp to Use Identity
Now that we have created our identity database, we can update Program.cs to configure identity.
We are trying to use identity to protect our web application, which means that we need to use the authentication middleware to implement this type of protection.
First add app.UseAuthentication() just before the app.UseAuthorization() call, as we did before (remember, we Authenticate before we Authorise).
Then earlier in the file before the builder.Build() call we add the dependency injection to configure the authentication middleware to add identity to the workflow of the authentication middleware.
builder.Services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
At the moment we will use the default IdentityUser and IdentityRole classes, but we can (and usually will) subclass these with additional fields required for our applications, so these types will be substitutes for the updated ones as and when necessary.
The AddEntityFrameworkStores<ApplicationDbContext>() tells the identity system that this is the database it needs to connect to in order to work with the user data.
With the framework in place we can now configure the behaviour of the identity system by updating the above code:
builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
options.Password.RequiredLength = 8;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>();
Then configure the cookie behaviour, similar to as we did previously, but not quite the same, after the AddIdentity configuration:
builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/Account/Login";
options.AccessDeniedPath = "/Account/AccessDenied";
});
Core Identity Classes
There are two main classes to work with in ASP.NET security:
- The SignInManager which helps us to verify credentials and create security contexts and thus the authentication.
- The UserManager which help us to retrieve all of the user information stored in the database.
For authorization, we still have to create our own policies and we need custom authorization handlers.
User Registration Workflow
User registration works like this:
sequenceDiagram
actor User
User ->> +Identity: Register
Identity ->> +Database: Create the User
Database ->> -Identity: User created
Identity ->> -User: Send Email
User ->> +Identity: Verifies the Email
Identity ->> Identity: Validates the link
Identity ->> Database: Update user record as validated
Identity ->> -User: Return Success
User Registration
Start be creating a User Registration Page. Right-click on the Pages folder, Add an Account folder in the Solution Explorer, then on this and select Add Razor Page.... Create an empty razor page called Register. Create a class for the ViewModel (in the cshtml.cs file as is standard):
public class RegisterViewModel
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
public string Email { get; set; } = string.Empty;
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; } = string.Empty;
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; } = string.Empty;
}
Add this to the PageModel as a bind-able class:
public class RegisterModel : PageModel
{
[BindProperty]
public RegisterViewModel Input { get; set; } = new RegisterViewModel();
public void OnGet()
{
}
}
Then edit the HTML markup:
@page
@model WebApp.Pages.RegisterModel
@{
}
<h3>User Registration</h3>
<br />
<div class="container border" style="padding: 20px;">
<form method="post">
<div class="text-danger" asp-validation-summary="All"></div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="RegisterViewModel.Email" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="RegisterViewModel.Email" class="form-control" />
<span class="text-danger" asp-validation-for="RegisterViewModel.Email"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="RegisterViewModel.Password" class="form-label"></label>
</div>
<div class="col-5">
<input type="password" asp-for="RegisterViewModel.Password" class="form-control" />
<span class="text-danger" asp-validation-for="RegisterViewModel.Password"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="RegisterViewModel.ConfirmPassword" class="form-label"></label>
</div>
<div class="col-5">
<input type="password" asp-for="RegisterViewModel.ConfirmPassword" class="form-control" />
<span class="text-danger" asp-validation-for="RegisterViewModel.ConfirmPassword"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<button type="submit" class="btn btn-primary">Register</button>
</div>
<div class="col-5">
</div>
</div>
</form>
</div>
We can now test this page by manually entering /account/register to the base URL in the browser when we run, this should display correctly and the validation should appear properly when we hit the Register button.
Back in the PageModel code behind we will want access to the user manager, so create a constructor so this can be injected:
private readonly UserManager<IdentityUser> _userManager;
public RegisterModel(UserManager<IdentityUser> userManager)
{
_userManager = userManager;
}
Now we can add a post method to process the input:
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
// Validate the Email address does not exist (optional as we added a check)
// Create the user
var user = new IdentityUser
{
UserName = RegisterViewModel.Email,
Email = RegisterViewModel.Email
};
var result = await _userManager.CreateAsync(user, RegisterViewModel.Password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return Page();
}
return RedirectToPage("/Account/Login");
}
User Login
The login page is similar to the one made in the previous section, so this can be copied across to /Account with a few changes as detailed:
Login.cshtml:
- Namespace changed
- Fields for Credential object changed (e.g.
UserName->Email) - Added a title
- Add a 'Sign up' Link.
@page
@model WebApp.Pages.Account.LoginModel
@{
}
<br />
<div class="container border" style="padding: 20px;">
<form method="post">
<div class="text-danger" asp-validation-summary="All"></div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="Credential.Email" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="Credential.Email" class="form-control" />
<span class="text-danger" asp-validation-for="Credential.Email"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="Credential.Password" class="form-label"></label>
</div>
<div class="col-5">
<input type="password" asp-for="Credential.Password" class="form-control" />
<span class="text-danger" asp-validation-for="Credential.Password"></span>
</div>
</div>
<div class="row mb-3 form-check">
<div class="col-2">
<input type="checkbox" asp-for="Credential.RememberMe" class="form-check-input" />
<label asp-for="Credential.RememberMe" class="form-check-label"></label>
</div>
<div class="col-5">
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<button type="submit" class="btn btn-primary">Login</button>
</div>
<div class="col-5">
<a accesskey="R" class="btn btn-link" asp-page="/Account/Register">Register</a>
</div>
</div>
</form>
</div>
Login.cshtml.cs:
- Namespace changed
- Removed implementation code in
OnPostAsyncas this will change - Recreated an updated
Credentialclass with the name and references updated toCredentialViewModelclass
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;
namespace WebApp.Pages.Account
{
public class LoginModel : PageModel
{
[BindProperty]
public CredentialViewModel Credential { get; set; } = new CredentialViewModel();
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
}
}
public class CredentialViewModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = string.Empty;
[Display(Name = "Remember Me")]
public bool RememberMe { get; set; }
}
}
Now we need to implement the the login functionality. We will need a reference to the SignInManager so inject this via the constructor:
private readonly SignInManager<IdentityUser> _signInManager;
...
public LoginModel(SignInManager<IdentityUser> signInManager)
{
_signInManager = signInManager;
}
Then Implement the OnPostAsync() method:
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var result = await _signInManager.PasswordSignInAsync(
Credential.Email,
Credential.Password,
Credential.RememberMe,
lockoutOnFailure: false);
if (result.Succeeded)
{
return RedirectToPage("/Index");
}
if (result.IsLockedOut)
{
ModelState.AddModelError("Login", "You are locked out.");
}
else
{
ModelState.AddModelError("Login", "Failed to login.");
}
return Page();
}
}
The user we created does not yet have their email confirmed, we can check for this by setting SignIn.RequireConfirmedEmail in the options section of Program.cs:
builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
options.Password.RequiredLength = 8;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.User.RequireUniqueEmail = true;
options.SignIn.RequireConfirmedEmail = true;
})
Now when we run this and try to login we get the "Failed to Login" error displayed due to the password confirmation.
Email Confirmation
In the verify email we send a token. The token is generated, saved to the user record and sent to the user in an email as a link parameter.
When the user clicks the link an endpoint (page) is accessed on the website, this checks the supplied token with the one in the database, and if they match the user email is recorded as verified.
However, you can have a token that can do self validation like the JWT token does. Identity uses this approach.
How identity generates and validates the token
The token is not actually stored in the in the data store. It basically just generates a token by hashing some part of the user information that is unique to that user and then that token is sent to the email.
When the user clicks on the link in the email, the token will be sent to the server to be validated. The User ID is included with this request.
When the server validates the token, it takes the user ID, it retrieves the same information that it was used to generate the token and then it will try to generate another token. Because it uses the same user information (hashing algorithm and key), then it will always generate the same token, which can then be used to compare with the token in the url contained in the emailed link.
Back in Register.cshtml.cs the OnPostAsync method redirects to the login page on successful registration, but the correct flow is that we need to send an email notification to the user along with the confirmation token in a link. We need to update the success section as follows:
// Successful registration
//Generate a token and send email confirmation
var confirmationToken = await _userManager.GenerateEmailConfirmationTokenAsync(user);
// TODO: Send the token via email to the user with a confirmation link
var confirmationPage = Url.PageLink(
pageName: "/Account/ConfirmEmail",
values: new { userId = user.Id, token = confirmationToken }
);
return Redirect(confirmationPage ?? "");
Before we can test this we have to set up the token provider in Program.cs by chaining it to the AddIdentity method call:
builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
...
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
This adds the default token provider, but you could specify other providers.
You cannot run this yet as Url.PageLink(...) will return null until the page actually exists, so create this page as an empty Razor page.
The new page will have to read the two parameters we sent to it above (userId and token) which is done in the usual way by adding the parameters to the OnGetAsync signature. We will also need to inject the UserManager again.
ConfirmEmail.cshtml.cs:
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace WebApp.Pages.Account;
public class ConfirmEmailModel : PageModel
{
[BindProperty]
public string Message { get; set; } = string.Empty;
private readonly UserManager<IdentityUser> _userManager;
public ConfirmEmailModel(UserManager<IdentityUser> userManager)
{
_userManager = userManager;
}
public async Task<IActionResult> OnGetAsync(string userId, string token)
{
var user = await _userManager.FindByIdAsync(userId);
if (user != null)
{
var result = await _userManager.ConfirmEmailAsync(user, token);
if (result.Succeeded)
{
Message = "Email confirmed successfully. You may now login.";
return Page();
}
}
Message = "Email validation failed.";
return Page();
}
}
And update the page markup to display the message field, ConfirmEmail.cshtml:
@page
@model WebApp.Pages.Account.ConfirmEmailModel
@{
}
<p>
@Model.Message
</p>
We can now test this by registering a new user, which will redirect to the ConfirmationEmail page, which automatically confirms the email and displays a user on the screen. If you look at the field EmailConfirmed in the [AspNetUsers] table for this user, you will see it is now set at 1 (true).
It should now also be possible to log into the web app.
Send Email
First we need an SMTP server to actually send the email, two good options are:
To send an email from Brevo you first need to configure a sender via the Settings -> Senders, Domains, IPs section of the site.
With this configured, we can move to the SMTP & API page to generate an SMTP key.
Back in Register.cshtml.cs the OnPostAsync method, we need to modify the success section again to create an email message top send via the account we just created:
// Successful registration - Generate a token and send email confirmation
var confirmationToken = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var confirmationLink = Url.PageLink(
pageName: "/Account/ConfirmEmail",
values: new { userId = user.Id, token = confirmationToken }
);
var message = new MailMessage
{
To = { new MailAddress(user.Email) },
From = new MailAddress("stuart.northcott@gmail.com"),
Subject = "Confirm your email",
Body = $"Please confirm your account by clicking this link: <a href='{confirmationLink}'>Confirm Email</a>",
IsBodyHtml = true
};
using (var smtpClient = new SmtpClient("smtp-relay.brevo.com", 587))
{
smtpClient.Credentials = new System.Net.NetworkCredential(
"aaaaa@smtp-brevo.com",
"xxxxxx-xxxxxxxxxxxxxxxxxxx-xxxxxxxxxxx");
await smtpClient.SendMailAsync(message);
}
return RedirectToPage("/Account/Login");
This can now be tested by running and navigating to the /Account/Register page.
Refactor Email Sending Code
We can extract the code that creates and send the email to make it reusable in other situations where we want to send emails. We also need to remove the configuration values to appsettings.json and put the username and password into a secrets file:
appsettings.json:
{
...,
"SMTP": {
"From": "stuart.northcott@gmail.com",
"Host": "smtp-relay.brevo.com",
"Port": 587
//"Username": "aaa@smtp-brevo.com", - Put in Secrets.json
//"Password": "xxxx-xxxxxxxxxxxxxxxxx-xxxxxx" - Put in Secrets.json
}
}
Now we crate a /Settings folder and add a class SmtpSettings to hold these settings:
namespace WebApp.Settings;
public class SmtpSettings
{
public string From { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 587;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
Next we make the following addition to Program.cs just before the builder.Build() call to use the options pattern of ASP.Net Core to load the settings.
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("SMTP"));
This allows any file that needs an instance of the SmtpSettings class to be passed the populated/configured object from the settings file.
Now create a service class to encapsulate the email sending functionality. Create a folder /Services and add to this a new class EmailService. If we end up with too many services these can be moved to a class library instead.
In the EmailService class we define one public method to send a message, and inject the required referenced classes via a constructor:
using Microsoft.Extensions.Options;
using System.Net.Mail;
using WebApp.Settings;
namespace WebApp.Services;
public class EmailService
{
private readonly IOptions<SmtpSettings> _smtpSettings;
public EmailService(IOptions<SmtpSettings> smtpSettings)
{
_smtpSettings = smtpSettings;
}
public async Task SendEmailAsync(string toEmail, string subject, string body)
{
var message = new MailMessage
{
To = { new MailAddress(toEmail) },
From = new MailAddress(_smtpSettings.Value.From),
Subject = subject,
Body = body,
IsBodyHtml = true
};
using (var smtpClient = new SmtpClient(
_smtpSettings.Value.Host,
_smtpSettings.Value.Port))
{
smtpClient.Credentials = new System.Net.NetworkCredential(
_smtpSettings.Value.Username,
_smtpSettings.Value.Password);
await smtpClient.SendMailAsync(message);
}
}
}
We want this service injected into any class that needs it, so extract the interface by pressing [Ctrl]+. on the class name and selecting Extract Interface...
Now we need to register this in Program.cs as usual:
builder.Services.AddSingleton<IEmailService, EmailService>();
(Singleton as this can be shared)
Finally, update the RegisterModel.cshtml.cs file to use this:
public class RegisterModel : PageModel
{
private readonly UserManager<IdentityUser> _userManager;
private readonly IEmailService _emailService;
...
public RegisterModel(UserManager<IdentityUser> userManager, IEmailService emailService)
{
_userManager = userManager;
_emailService = emailService;
}
...
public async Task<IActionResult> OnPostAsync()
{
...
// Successful registration - Generate a token and send email confirmation
var confirmationToken = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var confirmationLink = Url.PageLink(
pageName: "/Account/ConfirmEmail",
values: new { userId = user.Id, token = confirmationToken }
);
await _emailService.SendEmailAsync(
user.Email,
"Confirm your email",
$"Please confirm your account by clicking this link: <a href='{confirmationLink}'>Confirm Email</a>");
return RedirectToPage("/Account/Login");
}
}
Now we should be able to test this again.
Sign Out
The sign-out functionality for a WebApp is very similar to the previous project. In that project we have a shared file /Pages/Shared/_LoginStatusPartial.cshtml and a page that uses it /Pages/Account/Logout.cshtml which we can copy and use as a basis for this WebApp, so we end up with the following:
/Pages/Account/Logout.cshtml.cs:
Instead of using HttpContext, we use the SignIn manager which needs to be injected into the class
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace WebApp.Pages.Account;
public class LogoutModel : PageModel
{
private readonly SignInManager<IdentityUser> _signInManager;
public LogoutModel(SignInManager<IdentityUser> signInManager)
{
_signInManager = signInManager;
}
public async Task<IActionResult> OnPostAsync()
{
await _signInManager.SignOutAsync();
return RedirectToPage("/account/login");
}
}
The page itself can remain empty as we only use this to logout and redirect in this case.
We also want to put the /Pages/Shared/_LoginStatusPartial.cshtml into the layout view _Layout.cshtml. This can be a copy and paste to the same location as the previous project, after the <div...</div> section holding the Index and Privacy links:
_Layout.cshtml:
<div class="mr-2">
<partial name="_LoginStatusPartial" />
</div>
This is now ready for testing again.
Collecting More User Information
We have the option to collect additional information about the user, for example during registration. Extra information could be Job Title, Department, Date of Birth, Start Date, etc. We currently store the user information in the IdentityUser class which is defined in the Identity Framework system, as first registered in Program.cs:
builder.Services.AddIdentity<IdentityUser, IdentityRole>(options =>
{
...
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
IdentityUser is linked to Entity Framework and saved inside our data store (SQL Server) inside the AspNetUsers table. This type comes with a bunch of pre-defined fields such as Email, PhoneNumber, etc.
We can extend this with additional columns by deriving from the IdentityUser class with the additional columns we require. To do this create a new folder /Data/Accounts and add a new class, for example User which derives from the IdentityUser class:
using Microsoft.AspNetCore.Identity;
namespace WebApp.Data.Accounts;
public class User : IdentityUser
{
public String Department { get; set; } = string.Empty;
public String Position { get; set; } = string.Empty;
}
Now in the Register page (for example) we can collect this additional information. In the code-behind add the two new fields to the View Model
public class RegisterViewModel
{
...
[Required]
public String Department { get; set; } = string.Empty;
[Required]
public String Position { get; set; } = string.Empty;
}
Also, for the code-behind class itself we need to assign these new fields to the User model in the OnPostAsync() method:
// Create the user
var user = new User
{
UserName = RegisterViewModel.Email,
Email = RegisterViewModel.Email,
Department = RegisterViewModel.Department,
Position = RegisterViewModel.Position
};
Add the new fields above the register button in the page design:
<div class="row mb-3">
<div class="col-2">
<label asp-for="RegisterViewModel.Department" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="RegisterViewModel.Department" class="form-control" />
<span class="text-danger" asp-validation-for="RegisterViewModel.Department"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="RegisterViewModel.Position" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="RegisterViewModel.Position" class="form-control" />
<span class="text-danger" asp-validation-for="RegisterViewModel.Position"></span>
</div>
</div>
Now everywhere we reference IdentityUser we must replace this with our new derived class User. This can easily be done with a search and replace in the IDE. For example:
builder.Services.AddIdentity<IdentityUser, IdentityRole>(options => ...})
becomes
builder.Services.AddIdentity<User, IdentityRole>(options => ...})
Note: Beware of updating existing migrations and the base class for our new User class.
Fix the namespace issues (using WebApp.Data.Accounts;), then the project should build. Now we need to add the user class base type to the ApplicationDbContext inheritance (to basically say it's not just deriving from IdentityDbContext, but from the User derivative of that):
public class ApplicationDbContext : IdentityDbContext<User>
Now we need to create an EF Migration to generate the changes, and apply this to our database, via Package Manager Console:
add-migration AddDepartmentAndPositionUserColumns
update-database
This change is now reflected in the database.
Adding User Claims
As an alternative to extending the schema of the IdentityUser, we could also use claims.
Note: Remember, Claims are basically a key value pair that carries your information. As an example a driver's licence will contain a name, birth date, photo, address, etc.
Note: Going away from the course, I am going to add a Claim for a manger rather than reimplementing the Department and Position fields. This isn't great as I'm mixing approaches, but I wanted to keep the earlier example in place.
I'll start by adding this new field to the RegisterViewModel and HTML as before:
public class RegisterViewModel
{
...
[Required]
public String Manager { get; set; } = string.Empty;
}
<div class="row mb-3">
<div class="col-2">
<label asp-for="RegisterViewModel.Manager" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="RegisterViewModel.Manager" class="form-control" />
<span class="text-danger" asp-validation-for="RegisterViewModel.Manager"></span>
</div>
</div>
Then we need to create the claims in the OnPostAsync() before the _userManager.CreateAsync( ... ); call, then after the call associate the claim with that user (I don't see any reason why this shouldn't be all done after successful user creation as this would be more efficient in the event of a failure while creating):
var claimManager = new Claim("Manager", RegisterViewModel.Manager);
var result = await _userManager.CreateAsync(user, RegisterViewModel.Password);
if (!result.Succeeded)
{
...
}
await _userManager.AddClaimAsync(user, claimManager);
The claim will be saved to the AspNetUserClaims table in the database.
To see this claim we can update _LoginStatusPartial.cshtml:
Hello, @User.Identity.Name (Manager: @User.Claims.FirstOrDefault(c => c.Type == "Manager")?.Value)
In a similar way we can check for claims elsewhere in code.
Using Roles
Roles vs Claims
Where Claims are specific attributes or assertions about a user (e.g., "Age: 25", "Department: Sales"). They provide fine-grained authorization, Roles are broad categories that group users (e.g., "Admin", "User", "Moderator"). They provide coarse-grained authorization.
For example a page my be restricted to only Admin, or Sales, or both. Conversely, a user can have multiple roles, so could be assigned to both Sales and Admin, which would be more difficult using a "Roles" claim.
Roles are more straightforward, if your requirement doesn't require complicated logic in order to give permissions to users to access certain resources then roles are ideal.
- Roles are simpler, but they cannot handle complicated scenarios.
- Claims are more flexible, but there's a little bit more work to do.
Where we have previously used the Authorize attribute, we can add the parameter `Roles¬ with a comma separated list of roles that are allowed access, for example:
[Authorize(Roles = "Admin, Manager")]
NOTE: This can be used with a Policy parameter.
You add a role much like a claim. First the role needs to be defined, for example in Program.cs:
...
var app = builder.Build();
// Seed the Admin role
using (var scope = app.Services.CreateScope())
{
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var adminRoleExists = await roleManager.RoleExistsAsync("Admin");
if (!adminRoleExists)
{
await roleManager.CreateAsync(new IdentityRole("Admin"));
}
}
...
You would probably want to keep this code out of program.cs in the real world. To checks for the role, add it if required, and add a new user to it:
// Seed the Admin role and user
using (var scope = app.Services.CreateScope())
{
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
// Create Admin role
var adminRoleExists = await roleManager.RoleExistsAsync("Admin");
if (!adminRoleExists)
{
await roleManager.CreateAsync(new IdentityRole("Admin"));
}
// Create Admin user (optional)
var adminUser = await userManager.FindByEmailAsync("admin@example.com");
if (adminUser == null)
{
adminUser = new User
{
UserName = "admin@example.com",
Email = "admin@example.com",
EmailConfirmed = true
};
await userManager.CreateAsync(adminUser, "Admin@123");
await userManager.AddToRoleAsync(adminUser, "Admin");
}
}
Again, I'd probably refactor this out into a method. Regardless, the key line is the call to the method AddToRoleAsync().
Creating a User Profile Page
Now lets create a user profile page. Add the empty razor page /Pages/Account/UserProfile and update as follows:
UserProfile.cshtml.cs:
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using WebApp.Data.Accounts;
namespace WebApp.Pages.Account;
public class UserProfileModel : PageModel
{
private readonly UserManager<User> _userManager;
[BindProperty]
public UserProfileViewModel UserProfile { get; set; } = new UserProfileViewModel();
[BindProperty]
public string? SuccessMessage { get; set; }
public UserProfileModel(UserManager<User> userManager)
{
_userManager = userManager;
}
public async Task<IActionResult> OnGetAsync()
{
SuccessMessage = string.Empty;
var (user, managerClaim, isAdmin) = await GetUserInfoAsync();
if (user == null)
{
return NotFound();
}
UserProfile.Email = User.Identity?.Name ?? string.Empty;
UserProfile.Department = user.Department;
UserProfile.Position = user.Position;
UserProfile.Manager = managerClaim?.Value ?? "Unknown";
UserProfile.IsAdmin = isAdmin;
SuccessMessage = "Profile updated successfully.";
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
// Handle profile updates if needed
if (!ModelState.IsValid)
{
return Page();
}
try
{
var (user, managerClaim, sAdmin) = await GetUserInfoAsync();
if (user == null)
{
return NotFound();
}
if (managerClaim != null)
await _userManager.ReplaceClaimAsync(user, managerClaim, new Claim(managerClaim.Type, UserProfile.Manager));
var roles = await _userManager.GetRolesAsync(user);
if (roles != null)
{
if (UserProfile.IsAdmin && !roles.Contains("Admin"))
{
await _userManager.AddToRoleAsync(user, "Admin");
}
else if (!UserProfile.IsAdmin && roles.Contains("Admin"))
{
await _userManager.RemoveFromRoleAsync(user, "Admin");
}
}
if (user.Department != UserProfile.Department)
user.Department = UserProfile.Department;
if (user.Position != UserProfile.Position)
user.Position = UserProfile.Position;
await _userManager.UpdateAsync(user);
}
catch (Exception)
{
ModelState.AddModelError("UserProfile", "An error occurred while updating your profile. Please try again.");
}
return Page();
}
private async Task<(User? user, Claim? managerClaim, bool isAdmin)> GetUserInfoAsync()
{
var user = await _userManager.FindByNameAsync(User.Identity?.Name ?? "");
if (user == null)
{
return (null, null, false);
}
var claims = await _userManager.GetClaimsAsync(user);
// Department & Position are properties on the User class
var managerClaim = claims.FirstOrDefault(c => c.Type == "Manager");
var roles = await _userManager.GetRolesAsync(user);
var isAdmin = roles.Contains("Admin");
return (user, managerClaim, isAdmin);
}
}
public class UserProfileViewModel
{
public string Email { get; set; } = string.Empty;
[Required]
public string Department { get; set; } = string.Empty;
[Required]
public string Position { get; set; } = string.Empty;
[Required]
public string Manager { get; set; } = string.Empty;
public bool IsAdmin { get; set; }
}
UserProfile.cshtml:
@page
@model WebApp.Pages.Account.UserProfileModel
@{
}
<h3>User Details</h3>
<br />
<div class="container border" style="padding: 20px;">
<form method="post">
<div class="text-danger" asp-validation-summary="All"></div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="UserProfile.Email" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="UserProfile.Email" class="form-control" readonly />
<span class="text-danger" asp-validation-for="UserProfile.Email"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="UserProfile.Department" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="UserProfile.Department" class="form-control" />
<span class="text-danger" asp-validation-for="UserProfile.Department"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="UserProfile.Position" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="UserProfile.Position" class="form-control" />
<span class="text-danger" asp-validation-for="UserProfile.Position"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="UserProfile.Manager" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="UserProfile.Manager" class="form-control" />
<span class="text-danger" asp-validation-for="UserProfile.Manager"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="UserProfile.IsAdmin" class="form-label"></label>
</div>
<div class="col-5">
<div class="form-check">
<input type="checkbox" asp-for="UserProfile.IsAdmin" class="form-check-input" />
<span class="text-danger" asp-validation-for="UserProfile.IsAdmin"></span>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<button type="submit" class="btn btn-primary">Update</button>
</div>
<div class="col-5">
</div>
</div>
</form>
</div>
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success" role="alert">
@Model.SuccessMessage
</div>
}