我可以对ConcurrentDictionary执行原子性的“尝试获取或添加”操作吗?

时间:2019-07-14 08:03:02

标签: asp.net-core oauth task oidc concurrentdictionary

在我的ASP.NET Core MVC Web应用程序(使用OIDC)中,我有一个类,该类在过期之前自动刷新访问者身份验证cookie中存储的access_token

它基于IdentityServer4示例中的AutomaticTokenManagementCookieEvents。在此处可用:https://github.com/IdentityServer/IdentityServer4/blob/0155beb2cea850144b6407684a2eda22e4eea3db/samples/Clients/src/MvcHybridAutomaticRefresh/AutomaticTokenManagement/AutomaticTokenManagementCookieEvents.cs

static readonly ConcurrentDictionary<String,Object> _pendingRefreshes = new ConcurrentDictionary<String,Object>();

public override async Task ValidatePrincipal( CookieValidatePrincipalContext context )
{
    DateTime accessTokenExpiresAt = GetAccessTokenExpiry( context ); // gets the 'expires_at' value from `context.Properties.GetTokens();`
    String refreshToken = GetRefreshToken( context ); // Gets the 'refresh_token' value from `context.Properties.GetTokens();`

    Boolean isExpired = DateTime.UtcNow > accessTokenExpiresAt;
    Boolean willExpireSoon = DateTime.UtcNow > accessTokenExpiresAt.Subtract( TimeSpan.FromSeconds( 60 ) );

    if( isExpired || willExpireSoon )
    {
        Boolean canRefresh = _pendingRefreshes.TryAdd( refreshToken, null );
        if( canRefresh )
        {
            try
            {
                await RefreshAccessTokenAsync( context, refreshToken );
            }
            finally
            {
                _pendingRefreshes.TryRemove( refreshToken );
            }
        }
        else
        {
            // TODO: What should happen here?
        }
    } 

}

private async Task RefreshAccessTokenAsync( CookieValidatePrincipalContext context, String refreshToken )
{
    // using IdentityModel.Client.HttpClientTokenRequestExtensions.RequestRefreshTokenAsync
    TokenResponse response = await this.httpClient.RefreshTokenAsync( refreshToken );
    if( response.IsError )
    {
        // (Error logging code here)

        if( response.Error == "invalid_grant" )
        {
            // Usually invalid_grant errors happen if the user's refresh_token has been revoked or expired
            // refresh_token expiry is separate from access_token expiry.
            // If a refresh_token has expired or been revoked the only thing to do is force the user to login again. `RejectPrincipal()` will send the user to the OIDC OP login page - though this will cause the user to lose their data if this is a POST request.
            context.RejectPrincipal();
        }
        else
        {
            // Something else bad happened. Don't invalidate the user's credentials unless they're actually expired, though.
            throw new Exception( "Unexpected error." );
        }
    }
    else
    {
        context.Properties.UpdateTokenValue( "access_token" , response.AccessToken  );
        context.Properties.UpdateTokenValue( "refresh_token", response.RefreshToken );

        DateTime newExpiresAt = DateTime.UtcNow + TimeSpan.FromSeconds( response.ExpiresIn );
        context.Properties.UpdateTokenValue( "expires_at", newExpiresAt.ToString( "o", CultureInfo.InvariantCulture ) );

        await context.HttpContext.SignInAsync( context.Principal, context.Properties );
    }
}

此代码的问题是,如果用户的浏览器在access_token到期后同时发出两个请求,则如果稍后在ASP.NET Core管道中针对该代码进行编码,则用户将收到一条错误消息。第二个同时请求使用现在过期的access_token

...如何获取它,以便使用过期的access_token的第二个并发请求将awaitTask相同(来自RefreshAccessTokenAsync)?

我的想法是这样的:

  1. _pendingRefreshes更改为ConcurrentDictionary<String,Task<String>>
  2. Boolean canRefresh = _pendingRefreshes.TryAdd( refreshToken, null );更改为以下内容(使用假设的TryGetOrAdd方法):

    Boolean addedNewTask = _pendingRefreshes
        .TryGetOrAdd(
            key: refreshToken,
            valueFactory: rt => this.RefreshTokenAsync( context, rt ),
            value: out Task task
        );
    
    if( addedNewTask )
    {
        // wait for the new access_token to be saved before continuing.
        await task;
    }
    else
    {
        if( isExpired )
        {
            // If the current request's access_token is already expired and its refresh_token is currently being refrehsed, then wait for it to finish as well, then update the access_token but only for this request's lifetime (i.e. don't call `ReplacePrincipal` or `SignInAsync`.
            await task;
        }
    }
    

问题是ConcurrentDictionary<TKey,TValue>没有一种TryGetOrAdd方法,我可以使用该方法原子地获取或添加新项目。

  • AddOrUpdate-不返回任何现有项目。不指示返回的值是否是现有项目。
  • GetOrAdd-不指示返回的值是否是现有项目。
  • TryAdd-不允许您使用相同的键原子地获取任何现有值。
  • TryGetValue-如果给定键没有值,则不允许您自动添加新项。
  • TryRemove-不允许您自动添加新项目。
  • TryUpdate-不允许您添加新项目。

使用lock可以解决此问题,但这否定了使用ConcurrentDictionary的优势。像这样:

Task<String> task;
Boolean addedNewTask;
lock( _pendingRefreshes )
{
    Boolean taskExists = _pendingRefreshes.TryGetValue( refreshToken, out task );
    if( taskExists )
    {
        addedNewTask = false;
    }
    else
    {
        task = RefreshAccessTokenAsync( context, refreshToken );
        if( !_pendingRefreshes.TryAdd( refreshToken, task ) )
        {
            throw new InvalidOperationException( "Could not add the Task." ); // This should never happen.
        }
        addedNewTask = true;
    }
}

if( addedNewTask || isExpired )
{
    String newAccessToken = await task;
    if( isExpired )
    {
        context.Properties.UpdateTokenValue( "access_token", newAccessToken );
    }
}

...或者在这种情况下这是ConcurrentDictionary的正确用法吗?

1 个答案:

答案 0 :(得分:0)

我学会了that ConcurrentDictionary does not guarantee that valueFactory won't be invoked exactly once per key,但是可能被多次调用-我无法忍受,因为我的valueFactory是一项昂贵的操作,具有副作用,可能会对用户体验产生负面影响(例如,不必要地使存储在用户Cookie中的令牌无效)。

我选择了使用lockDictionary<String,Task<TokenResponse>>的方法。我确实想知道如何改进它,并且非常感谢您提供任何反馈意见:

private static readonly Dictionary<String,Task<TokenResponse>> _currentlyRunningRefreshOperations = new Dictionary<String,Task<TokenResponse>>();

/// <summary>Returns <c>true</c> if a new Task was returned. Returns <c>false</c> if an existing Task was returned.</summary>
private Boolean GetOrCreateRefreshTokenTaskAsync( CookieValidatePrincipalContext context, String refreshToken, out Task<TokenResponse> task )
{
    lock( _currentlyRunningRefreshOperations )
    {
        if( _currentlyRunningRefreshOperations.TryGetValue( refreshToken, out task ) )
        {
            return false;
        }
        else
        {
            task = this.AttemptRefreshTokensAsync( context, refreshToken );
            _currentlyRunningRefreshOperations.Add( refreshToken, task );
            return true;
        }
    }
}

private async Task<TokenResponse> AttemptRefreshTokensAsync( CookieValidatePrincipalContext context, String refreshToken )
{
    try
    {
        TokenResponse response = await this.service.RefreshTokenAsync( refreshToken );
        if( response.IsError )
        {
            this.logger.LogWarning( "Encountered " + nameof(this.service.RefreshTokenAsync) + " error: {error}. Type: {errorType}. Description: {errorDesc}. refresh_token: {refreshToken}.", response.Error, response.ErrorType, response.ErrorDescription, refreshToken );

            return response;
        }
    }
    finally
    {
        lock( _currentlyRunningRefreshOperations )
        {
            _currentlyRunningRefreshOperations.Remove( refreshToken );
        }
    }
}