Search code examples
c#jwtasp.net-core-5.0

Configure JwtBearerOptions IssuerSigningKey based on user


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:

Authentication Controller

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:

StartUp.cs

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?


Solution

  • 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".

    Authorization Controller

    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:

    StartUp.cs

    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:

    DynamicKeyJwtTokenValidationHandler class

    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.