0

I'm tackling with JWT and their Refresh Tokens and couldn't find a good working example that serve performance and security at the same time.

Performance:: It must not hit the database every time a token refreshed.

Security:: Refresh token should be super secret more than access token because of the long-lifetime.

So I try to implement my own by using a combination of in-memory Cache and expired Token claims:

Step 1.

a) After a successful login an access-token with a unique GUID in JwtRegisteredClaimNames.Jti claim type generated..

b) Then refresh-token generated and save in memoryCache with the associated jti access-token value (unique GUID) as key

c) Both sends to Client App and stored in localStorage.

Step 2.

a)After the access-token expired, both access-token and refresh-token send to refresh controller.

b) Then jti claim in expired token sent to memoryCache as a cache-key to get the refresh-token from memory.

c) After checking the equality of -send refresh-token and -in-memory refresh-token, if equal, a new instance of both access-token and refresh-token generated and sends back to client app.

AuthService.cs

 private readonly IConfiguration _configuration;
    private readonly IMemoryCache _memoryCache;
    private readonly Claim _jtiClaim;
    public AuthService(IConfiguration configuration, IMemoryCache memoryCache)
    {
        _configuration = configuration;
        _memoryCache = memoryCache;
        _jtiClaim = new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString());

    }

    public string GenerateAccessToken(IList<Claim> claims)
    {
        claims.Add(_jtiClaim);
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JwtConfiguration:JwtKey"]));

        var jwtToken = new JwtSecurityToken(
            issuer: _configuration["JwtConfiguration:JwtIssuer"],
            audience: _configuration["JwtConfiguration:JwtIssuer"],
            claims: claims,
            notBefore: DateTime.UtcNow,
            expires: DateTime.UtcNow.AddMinutes(int.Parse(_configuration["JwtConfiguration:JwtExpireMins"])),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
        );

        return new JwtSecurityTokenHandler().WriteToken(jwtToken);
    }

    public string GenerateRefreshToken(ClientType clientType)
    {
        var randomNumber = new byte[32];
        using (var rng = RandomNumberGenerator.Create())
        {
            rng.GetBytes(randomNumber);
            var token = Convert.ToBase64String(randomNumber);

            var refreshToken = JsonConvert.SerializeObject(new RefreshToken(token, _jtiClaim.Value, clientType));

            _memoryCache.Set(_jtiClaim.Value, refreshToken, new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromDays(7)));
            return token;
        }
    }

    public RefreshToken GetRefreshToken(string jtiKey)
    {
        if (!_memoryCache.TryGetValue(jtiKey, out string refreshToken)) return null;
        _memoryCache.Remove(jtiKey);
        return JsonConvert.DeserializeObject<RefreshToken>(refreshToken);
    }

    public ClaimsPrincipal GetPrincipalFromExpiredToken(string accessToken)
    {
        var tokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = false,
            ValidateIssuer = false,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JwtConfiguration:JwtKey"])),
            ValidateLifetime = false //here we are saying that we don't care about the token's expiration date
        };

        var tokenHandler = new JwtSecurityTokenHandler();
        var principal = tokenHandler.ValidateToken(accessToken, tokenValidationParameters, out var securityToken);
        if (!(securityToken is JwtSecurityToken jwtSecurityToken) || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
            throw new SecurityTokenException("Invalid token");

        return principal;
    }

AuthController.cs

        private readonly SignInManager<User> _signInManager;
    private readonly UserManager<User> _userManager;
    private readonly AuthService _authService;
    private readonly IMemoryCache _memoryCache;
    private readonly DataContext _context;

    public AuthController(UserManager<User> userManager, AuthService authService,
        SignInManager<User> signInManager, DataContext context)
    {
        _userManager = userManager;
        _authService = authService;
        _signInManager = signInManager;
        _context = context;
    }

    [HttpPost]
    public async Task<ActionResult> Login([FromBody] LoginDto model)
    {
        var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, false, false);

        if (!result.Succeeded) return BadRequest(new { isSucceeded = result.Succeeded, errors= "INVALID_LOGIN_ATTEMPT" });

        var appUser = _userManager.Users.Single(r => r.Email == model.Email);
        return Ok(new
        {
            isSucceeded = result.Succeeded,
            accessToken = _authService.GenerateAccessToken(GetClaims(appUser)),
            refreshToken = _authService.GenerateRefreshToken(model.ClientType)
        });
    }
    [HttpPost]
    public ActionResult RefreshToken([FromBody] RefreshTokenDto model)
    {
        var principal = _authService.GetPrincipalFromExpiredToken(model.AccessToken);
        var jtiKey = principal.Claims.Single(a => a.Type == JwtRegisteredClaimNames.Jti).Value;
        var refreshToken = _authService.GetRefreshToken(jtiKey);
        if (refreshToken == null)
            return BadRequest("Expired Refresh Token");
        if (refreshToken.Token != model.RefreshToken)
            return BadRequest("Invalid Refresh Token");
        return Ok(new
        {
            isSucceeded = true,
            accessToken = _authService.GenerateAccessToken(principal.Claims.SkipLast(1).ToList()),
            refreshToken = _authService.GenerateRefreshToken(model.ClientType)
        });

    }

I'm not sure this is a good implementation for refresh-token cause refresh-token may be compromised in client-app.

Can you suggest me a better solution over this?

Mehrdad Kamali
  • 87
  • 3
  • 13

1 Answers1

2

If it comes to security then performance is less important. But for a refresh token, being long lived, the times that the database is hit are to be neglected.

An in-memory cache is not the place to store refresh tokens. In case of a shutdown all refresh tokens will become invalid. So you'll need to persist the token anyways.

A policy can be to allow only one refresh token at a time (persisted in the database) and on login or refresh replace the refresh token with the new token, which will invalidate the used refresh token.

One thing you can do to make things safer is to use a fixed expiration time for the refresh token. In that case you'll force the user to login after a fixed time. Limiting the window that the token can be compromised.

The alternative is to make the token less long lived and use a sliding expiration, meaning that each time the refresh token is used, the expiration is reset. In that case it can occur that the user never has to login again, while on refreshing you can do some checks.

Requiring both the access token and the refresh token is not making things safer. As the access token may already be expired (and compromised) and multiple access tokens can exist. Requesting a new access token doesn't invalidate the current token and you don't want to verify the acccess token on each call.

You cannot simply trust the tokens from itself. You'll need to define rules to detect suspicious use of either token. Like check the number of calls per minute, or something like that.

Or you can check the current ip address. For that include the ip address as claim. If the current ip address doesn't match the ip address from the access token then deny access in order to force the client to refresh the access token.

On refresh, if the ip address is unknown (not in the list of known ip addresses for this user) then the user needs to login. If succesful you can add the ip address to the list of validated ip addresses. And you can send a mail to the user that there was a login from another ip address.

You can use in-memory cache to detect doubtful use of the access token. In which case you can revoke the refresh token (just delete it from the database), having the user to login again.

  • Thanks for your great answer. I will use Redis in production (I use memoryCache for simplicity in this question) cause as far as I know it has the ability to persist data in some level and it's good enough for this scenario, I think. I want that users can login in multiple devices with different refresh-token so I think policy solution doesn't fit in this case. I will use a fix time for a refresh token, ( 7 days in this case) and maybe I use sliding in this case (in redis or memorycache). I Think saving IP and even remove the associated refresh-token in db (redis) when it changed is nice. – Mehrdad Kamali Aug 21 '18 at 08:52