Предотвращение утечки токенов обновления через веб-API - Реализация

Я занимаюсь JWT и их токенами обновления и не могу найти хороший рабочий пример, который одновременно обеспечивает производительность и безопасность.

Производительность :: Он не должен попадать в базу данных при каждом обновлении токена.

Безопасность :: токен обновления должен быть суперсекретным, а не токеном доступа из-за длительного срока службы.

Поэтому я пытаюсь реализовать свой собственный, используя комбинацию заявлений о кешировании в памяти и просроченных токенах:

Шаг 1.

a) После успешного входа в систему создается токен доступа с уникальным GUID в типе утверждения JwtRegisteredClaimNames.Jti.

б) Затем создается токен обновления и сохраняется в memoryCache со связанным значением токена доступа jti (уникальный GUID) в качестве ключа.

c) Оба отправляются в клиентское приложение и сохраняются в localStorage.

Шаг 2.

a) После истечения срока действия токена доступа и токен доступа, и токен обновления отправляются на контроллер обновления.

б) Затем запрос jti в токене с истекшим сроком действия, отправленном в memoryCache в качестве ключа кеширования, для получения токена обновления из памяти.

c) После проверки равенства -send refresh-token и -in-memory refresh-token, если они равны, создается новый экземпляр как токена доступа, так и токена обновления, который отправляется обратно в клиентское приложение.

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)
        });

    }

Я не уверен, что это хорошая реализация для токена обновления, потому что токен обновления может быть скомпрометирован в клиентском приложении.

Вы можете предложить мне лучшее решение?


person Mehrdad Kamali    schedule 19.08.2018    source источник


Ответы (1)


Если речь идет о безопасности, производительность менее важна. Но для токена обновления, который является долгоживущим, следует пренебречь временем попадания в базу данных.

Кэш в памяти - не место для хранения токенов обновления. В случае отключения все токены обновления станут недействительными. Таким образом, вам все равно нужно сохранить токен.

Политика может разрешать только один токен обновления за раз (сохраняется в базе данных) и при входе в систему или обновлении заменять токен обновления новым токеном, что делает недействительным используемый токен обновления.

Одна вещь, которую вы можете сделать, чтобы сделать вещи более безопасными, - это использовать фиксированное время истечения срока действия для токена обновления. В этом случае вы заставите пользователя войти в систему через определенное время. Ограничение окна, в котором токен может быть скомпрометирован.

Альтернативный вариант - сделать токен менее долговечным и использовать скользящее истечение срока действия, что означает, что каждый раз, когда используется токен обновления, срок действия сбрасывается. В этом случае может случиться так, что пользователю больше не придется входить в систему, а при обновлении вы можете выполнить некоторые проверки.

Требование и токена доступа, и токена обновления не делает ситуацию безопаснее. Поскольку токен доступа может быть просрочен (и скомпрометирован) и могут существовать токены множественного доступа. Запрос нового токена доступа не делает текущий токен недействительным, и вы не хотите проверять токен доступа при каждом вызове.

Вы не можете просто доверять токенам от себя. Вам нужно будет определить правила для обнаружения подозрительного использования любого из токенов. Например, проверить количество звонков в минуту или что-то в этом роде.

Или вы можете проверить текущий IP-адрес. Для этого укажите IP-адрес в качестве утверждения. Если текущий IP-адрес не совпадает с IP-адресом из токена доступа, запретите доступ, чтобы заставить клиента обновить токен доступа.

При обновлении, если IP-адрес неизвестен (отсутствует в списке известных IP-адресов для этого пользователя), пользователю необходимо войти в систему. В случае успеха вы можете добавить IP-адрес в список проверенных IP-адресов. И вы можете отправить пользователю письмо о том, что был логин с другого ip адреса.

Вы можете использовать кеш в памяти для обнаружения сомнительного использования токена доступа. В этом случае вы можете отозвать токен обновления (просто удалить его из базы данных), заставив пользователя снова войти в систему.

person Ruard van Elburg    schedule 19.08.2018
comment
Спасибо за отличный ответ. Я буду использовать Redis в производстве (я использую memoryCache для простоты в этом вопросе), потому что, насколько я знаю, он может сохранять данные на каком-то уровне, и я думаю, что этого достаточно для этого сценария. Я хочу, чтобы пользователи могли входить в систему на нескольких устройствах с разными токенами обновления, поэтому я думаю, что политическое решение в этом случае не подходит. Я буду использовать фиксированное время для токена обновления (в данном случае 7 дней) и, возможно, в этом случае я использую скольжение (в redis или memorycache). Я думаю, что сохранить IP и даже удалить связанный токен обновления в db (redis) при его изменении - это хорошо. - person Mehrdad Kamali; 21.08.2018