在我的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
的第二个并发请求将await
与Task
相同(来自RefreshAccessTokenAsync
)?
我的想法是这样的:
_pendingRefreshes
更改为ConcurrentDictionary<String,Task<String>>
。将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
的正确用法吗?
答案 0 :(得分:0)
我学会了that ConcurrentDictionary
does not guarantee that valueFactory
won't be invoked exactly once per key,但是可能被多次调用-我无法忍受,因为我的valueFactory
是一项昂贵的操作,具有副作用,可能会对用户体验产生负面影响(例如,不必要地使存储在用户Cookie中的令牌无效)。
我选择了使用lock
和Dictionary<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 );
}
}
}