I'm getting started with API Authentication in NET Core utilizing Jwt and as per usual, I got to read a couple of examples and tutorials, and one thing I noticed is that most of them have the SymmetricSecutityKey
generation based on either a known string stored somewhere(be it a file or hardcoded) or a randomized output. I managed to get the authentication working, but now I've stuck with the following: How to set up the StartUp.cs
configuration so it will validate the IssueSigningKey
parameter checks more than one key? Bellow, a snippet of working code:
var authClaims = new[] {
new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Iat, DateTime.Now.Ticks.ToString())
};
var ssKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("7S79jvOkEdwoRqHx"));
var securityToken = new JwtSecurityToken(
issuer: _apiSettings.BearerValidIssuer,
audience: _apiSettings.BearerValidAudience,
expires: DateTime.Now.AddHours(6),
claims: authClaims,
signingCredentials: new SigningCredentials(ssKey, SecurityAlgorithms.HmacSha256Signature)
);
return Ok(new
{
token = new JwtSecurityTokenHandler().WriteToken(securityToken),
expiration = securityToken.ValidTo,
});
And the current StartUp.cs
Config, regarding the Bearer Token:
services.AddAuthentication(options => {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options => {
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
{
ValidateIssuer = true,
ValidIssuer = _apiSettings.GetValue(typeof(string), "BearerValidIssuer").ToString(),
ValidateAudience = true,
ValidAudience = _apiSettings.GetValue(typeof(string), "BearerValidAudience").ToString(),
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("7S79jvOkEdwoRqHx")),
};
});
I understand that it would be common practice, at least for the sake of example, to have the string generating the symmetric keys stored somewhere in a file (a .json config file, for example), but I would like to generate it and store them in a database along with user info. That key would be passed to the user at some point and then it would be used to generate access tokens via REST request. Is that achievable? Also adding if that is even practical in terms of security, or I would be fine with "just" storying the string in a file?
Alright, for the sake of leaving a reference to whoever has the same doubts, here's how I approached the problem.
First of all, I had to modify my Authentication controller so the signing credentials of the JwtSecurityToken
are created according to a string stored per user in the database. This string is being retrieved from an "Authorization" header of the request, and it is to be kept with the users. This is the part where you prove that "you are you". This Authorization token also has two specific Claims, a Role for "WebApi" access, and the user "uid".
public async Task<IActionResult> GetAuthenticationToken([FromBody] JSONBody json)
{
try
{
User user = await _userManager.FindByEmailAsync(json.Email);
if (user != null)
{
if (await _userManager.IsInRoleAsync(user, "WebApi"))
{
if (HttpContext.Request.Headers.TryGetValue("Authorization", out StringValues headerValues))
{
string sskeyString = headerValues.First();
if (user.SymmetricSecurityKeyString == sskeyString)
{
var authClaims = new[] {
new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Iat, DateTime.Now.Ticks.ToString()),
new Claim(ClaimTypes.Role, "WebApi"),
new Claim("uid", user.Id)
};
var ssKey = new SymmetricSecurityKey(Convert.FromBase64String(user.SymmetricSecurityKeyString));
var securityToken = new JwtSecurityToken(
issuer: _apiSettings.BearerValidIssuer,
audience: _apiSettings.BearerValidAudience,
expires: DateTime.Now.AddHours(6),
claims: authClaims,
signingCredentials: new SigningCredentials(ssKey, SecurityAlgorithms.HmacSha256Signature)
);
return Ok(new
{
token = new JwtSecurityTokenHandler().WriteToken(securityToken),
expiration = securityToken.ValidTo,
});
}
else
return Unauthorized(new { Message = "Token de authenticação Inválido." });
}
else
return Unauthorized(new { Message = "Header Authorization não encontrado." });
}
else
return Unauthorized(new { Message = "Este usuário não possui acesso à WebApi. Contate a DescontaNet para solicitar este perfil de acesso." });
}
else
return NotFound(new { Message = $"Usuário não encontrado para este email: {json.Email}" });
}
catch (Exception ex)
{
return StatusCode(500, ex.InnerException);
}
}
After reading more about the AddAuthentication().AddJwtBearer()
method, I learned that you can set multiple authentication schemes, each one with its own options. The most important option is the SecurityTokenValidators
property, which is an IList<ISecurityTokenValidators>
. This is what will validate any tokens sent to the API during Authentication. So StartUp.cs
now looks like this:
services.AddAuthentication()
.AddJwtBearer("apiToken", options => {
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
{
ValidateIssuer = true,
ValidIssuer = _apiSettings.GetValue(typeof(string), "BearerValidIssuer").ToString(),
ValidateAudience = true,
ValidAudience = _apiSettings.GetValue(typeof(string), "BearerValidAudience").ToString(),
ValidateIssuerSigningKey = true,
};
options.SecurityTokenValidators.Clear();
options.SecurityTokenValidators.Add(new DynamicKeyJwtValidationHandler(Configuration.GetConnectionString("DNDrive")));
});
// There is also a policy to require this specific token validation in certain methods
services.AddAuthorization(options => {
options.AddPolicy("WebApiPolicy", policy => {
policy.RequireRole("WebApi");
policy.RequireAuthenticatedUser();
policy.AddAuthenticationSchemes("apiToken");
});
A minor but important detail: calling services.SecurityTokenValidators.Clear()
makes sure any default schemes are removed and only yours is going to be checked during authentication. Speaking of which, I had to look up and make a validator of my own, that looks like this:
public class DynamicKeyJwtValidationHandler : JwtSecurityTokenHandler, ISecurityTokenValidator
{
private DNDriveContext db;
public DynamicKeyJwtValidationHandler(string connectionStr)
{
var optionsBuilder = new DbContextOptionsBuilder<DNDriveContext>();
optionsBuilder.UseSqlServer(connectionStr);
db = new DNDriveContext(optionsBuilder.Options);
}
private SecurityKey GetSSKeyForId(string id)
{
var user = db.User.Where(u => u.Id == id).FirstOrDefault();
if (user == null)
throw new Exception("User Id not found");
return new SymmetricSecurityKey(Convert.FromBase64String(user.SymmetricSecurityKeyString));
}
public override ClaimsPrincipal ValidateToken(string token, TokenValidationParameters validationParameters, out SecurityToken validatedToken)
{
// Read the token before starting validation
JwtSecurityToken incomingToken = ReadJwtToken(token);
// Extract external system ID from the token
string externalSystemId = incomingToken.Claims.First(claim => claim.Type == "uid").Value;
// Retrieve the Symmetric Security Key String from the database
SecurityKey publicKeyForExternalSystem = GetSSKeyForId(externalSystemId);
// Set up the Security Key for that user
validationParameters.IssuerSigningKey = publicKeyForExternalSystem;
// Framework default validation
return base.ValidateToken(token, validationParameters, out validatedToken);
}
}
The class inherits from JwtSecurityTokenHandler
to make the validation and implements the ISecurityTokenValidator
interface. In my case, it was important that each user had an individual token to validate against, hence why I access the database and retrieve a SymmetricSecurityKey
from a string stored in the database. How do I search for the user in the database? This token has a custom Claim
of type "uid". With the SecurityKey
it is supposed to validate against, I call the base.ValidateToken()
method, passing the incoming token and returning the result.
With this, and with that policy created in StartUp.cs
, I can easily validate the tokens for any method under a controller with this tag: [Authorize(Policy = "WebApiPolicy")]
. There are probably ways to improve the security, as the end user still has to keep his authentication key secured, but at least I managed to add an extra layer of validation, and the JWT tokens have an expiration date.