Building Secure ASP.NET API: User Login and Token Generation with Identity
If you’re developing a web application using ASP.NET Identity and you want to utilize the same user credentials (username and password) to authenticate via API, then this guide is for you. We will demonstrate how you can accept a username and password through an API and provide a Bearer Token in return, assuming successful authentication.
This guide doesn’t cover project setup or creation, as it assumes that you already have an ASP.NET Core API project in place. Instead, we’ll dive straight into the development of a controller that processes login requests.
The first step involves creating a request model that will accept the necessary parameters for a login operation. Here’s how you can do that:
/// <summary>
/// Represents a model for account login requests.
/// This model is used when a user attempts to log in.
/// </summary>
public class AccountLoginRequestModel
{
/// <summary>
/// Gets or sets the username for the login request.
/// This is the unique identifier for each user.
/// This property is required.
/// </summary>
[Required]
public string UserName { get; set; }
/// <summary>
/// Gets or sets the password for the login request.
/// This property is required.
/// </summary>
[Required]
public string Password { get; set; }
/// <summary>
/// Gets or sets the expiry time for the login request.
/// This could be used to ensure that the request is not older than a certain time,
/// or that the login session will not remain valid beyond this time.
/// This property is required.
/// </summary>
[Required]
public DateTime Expire { get; set; }
}
The properties UserName
and Password
in our model are self-explanatory - they will hold the user credentials required for the login operation. The Expire
property allows the user to set the expiration time for the token that we'll generate upon successful authentication. This lets the user dictate when the token should become invalid.
Next, let’s also define a response model. Our response model will include the generated token and its associated expiration time. This gives the client crucial information — the authentication token for subsequent requests, and the time at which they’ll need to renew the token.
/// <summary>
/// Represents a model for account login responses.
/// This model is used when the server is responding to a client's login request.
/// It inherits from the ResponseBaseModel class.
/// </summary>
public class AccountLoginResponseModel : ResponseBaseModel
{
/// <summary>
/// Gets or sets the authentication token for the user.
/// This token is usually used for subsequent requests to the server to verify the user's identity and permissions.
/// </summary>
public string Token { get; set; }
/// <summary>
/// Gets or sets the expiration time for the authentication token.
/// This indicates the time until which the token is valid. After this time, the user may need to log in again.
/// </summary>
public DateTime Expire { get; set; }
}
Now that we’ve defined our input and output models, let’s put together the method that will utilize these models.
In this method, we’ll be leveraging ASP.NET Core Identity to perform the actual credential verification and validation tasks. To access the necessary functionalities, we’ll employ Dependency Injection (DI) to inject two important services, as shown below.
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
The UserModel
class, which extends IdentityUser
, stores user information. To sign the user in, we employ the _signInManager.PasswordSignInAsync()
method. You can delve into the specifics of this method by referring to the SignInManager<TUser>.PasswordSignInAsync Method documentation provided by Microsoft.
Upon successful sign-in, we proceed to generate an authentication token, as illustrated below.
/// <summary>
/// Generates an encoded JWT (JSON Web Token) for a user.
/// </summary>
/// <param name="userId">The ID of the user for whom the token is being generated.</param>
/// <param name="device">The system or device information for user.</param>
/// <param name="expire">The date and time when the token should expire.</param>
/// <param name="roles">A list of the roles associated with the user, used for role-based authorization.</param>
/// <returns>A JWT as a string, ready for use in HTTP headers for authentication.</returns>
private string GenerateEncodedToken(string userId, string device, DateTime expire, IList<string> roles = null)
{
// Initialize a list of claims for the JWT. These include the user's ID and device information,
// a unique identifier for the JWT, and the time the token was issued.
List<Claim> claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Iat, DateTime.UtcNow.Ticks.ToString(), ClaimValueTypes.Integer64),
new Claim(ClaimTypes.System, device)
};
// If any roles are provided, add them to the list of claims. Each role is a separate claim.
if (roles?.Any() == true)
{
foreach (string role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
}
// Create the JWT security token and encode it.
// The JWT includes the claims defined above, the issuer and audience from the config, and an expiration time.
// It's signed with a symmetric key, also from the config, and the HMAC-SHA256 algorithm.
JwtSecurityToken jwt = new JwtSecurityToken(
issuer: _config.Value.JwtIssuer,
audience: _config.Value.JwtAudience,
claims: claims,
expires: expire,
signingCredentials: new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config.Value.JwtKey)),
SecurityAlgorithms.HmacSha256)
);
// Convert the JWT into a string format that can be included in an HTTP header.
string encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);
return encodedJwt;
}
You might have observed the use of _config
in our code. This object is used to access values stored in our appsettings.json
file, which typically contains application-level configurations and settings. Now, let's proceed to the main controller where we'll employ the aforementioned method to generate an authentication token and return it to the user.
/// <summary>
/// Handles the user login request. It receives the login request and returns an appropriate response model.
/// </summary>
/// <param name="loginRequest">The login request model with user credentials and expiration time.</param>
/// <returns>Returns an AccountLoginResponseModel containing an authentication token and its expiration time.</returns>
/// <response code="201">Returns when the login attempt is successful and a token is generated.</response>
/// <response code="400">Returns when the login attempt failed.</response>
[HttpPost]
[AllowAnonymous]
[Route("/account/login")]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<AccountLoginResponseModel> Login(AccountLoginRequestModel loginRequest)
{
// Attempt to sign in the user using the provided username and password.
var result = await _signInManager.PasswordSignInAsync(loginRequest.UserName, loginRequest.Password, false, lockoutOnFailure: false);
if (result.Succeeded)
{
// If login was successful, get the user and their roles.
var user = await _userManager.FindByNameAsync(loginRequest.UserName);
var userId = user.Id;
var userRoles = await _userManager.GetRolesAsync(user);
// Sign out the user to clear any existing sessions.
await _signInManager.SignOutAsync();
// Generate a new token for the user.
var tokenResult = GenerateEncodedToken(userId, "", loginRequest.Expire, userRoles);
// Return a successful response, including the new token.
return new AccountLoginResponseModel()
{
Token = tokenResult,
Expire = loginRequest.Expire
};
}
// If the login attempt failed, return a response with an empty token and the current time as the expiration.
return new AccountLoginResponseModel()
{
Token = "",
Expire = DateTime.Now
};
}
While the current error handling could certainly be enhanced, it serves to demonstrate the basic process of token generation. In future discussions, I intend to delve deeper into strategies for improving error handling and techniques for invalidating issued tokens.