【发布时间】:2019-11-23 07:41:31
【问题描述】:
在我的 ASP.NET Core MVC Web 应用程序(使用 OIDC)中,我有一个类可以在访问者身份验证 cookie 过期之前自动刷新存储在访问者身份验证 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 的正确用法是什么?
【问题讨论】:
-
在我看来,更简洁的解决方案是使用
GetOrAdd并将isExpired逻辑移动到删除项目的计时器。 -
我不确定
Lazy<T>是否有帮助。像这样ConcurrentDictionary<String, Lazy<Task<TokenResponse>>>。使用这种方法,valueFactory可能会运行不止一次,但只添加了 1 个Lazy<T>=> 当我们查询Lazy<T>.Value时任务只运行一次
标签: asp.net-core oauth task openid-connect concurrentdictionary