{"action":"created","issue":{"url":"https://api.github.com/repos/aspnet/AspNetCore/issues/10399","repository_url":"https://api.github.com/repos/aspnet/AspNetCore","labels_url":"https://api.github.com/repos/aspnet/AspNetCore/issues/10399/labels{/name}","comments_url":"https://api.github.com/repos/aspnet/AspNetCore/issues/10399/comments","events_url":"https://api.github.com/repos/aspnet/AspNetCore/issues/10399/events","html_url":"https://github.com/aspnet/AspNetCore/issues/10399","id":446228850,"node_id":"MDU6SXNzdWU0NDYyMjg4NTA=","number":10399,"title":"Identity: the token cannot be unprotected after a certain time / duration","user":{"login":"ehouarn-perret","id":11332444,"node_id":"MDQ6VXNlcjExMzMyNDQ0","avatar_url":"https://avatars1.githubusercontent.com/u/11332444?v=4","gravatar_id":"","url":"https://api.github.com/users/ehouarn-perret","html_url":"https://github.com/ehouarn-perret","followers_url":"https://api.github.com/users/ehouarn-perret/followers","following_url":"https://api.github.com/users/ehouarn-perret/following{/other_user}","gists_url":"https://api.github.com/users/ehouarn-perret/gists{/gist_id}","starred_url":"https://api.github.com/users/ehouarn-perret/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ehouarn-perret/subscriptions","organizations_url":"https://api.github.com/users/ehouarn-perret/orgs","repos_url":"https://api.github.com/users/ehouarn-perret/repos","events_url":"https://api.github.com/users/ehouarn-perret/events{/privacy}","received_events_url":"https://api.github.com/users/ehouarn-perret/received_events","type":"User","site_admin":false},"labels":[{"id":1143445347,"node_id":"MDU6TGFiZWwxMTQzNDQ1MzQ3","url":"https://api.github.com/repos/aspnet/AspNetCore/labels/area-identity","name":"area-identity","color":"5319e7","default":false}],"state":"open","locked":false,"assignee":null,"assignees":[],"milestone":null,"comments":2,"created_at":"2019-05-20T17:26:22Z","updated_at":"2019-05-21T13:20:24Z","closed_at":null,"author_association":"NONE","body":"We have an ASP.NET Core application using identity to deal with user identification. There is something wrong that happens when the user wants to confirm his /her email.\r\n\r\nIn the `Startup.ConfigureServices(IServiceCollection services)`, the setup is achieved following this configuration:\r\n\r\n```csharp\r\nservices  \r\n.AddIdentity<ApplicationUser, IdentityRole>(options =>\r\n            {\r\n                options.SignIn.RequireConfirmedEmail = true;\r\n                options.Password.RequireDigit = true;\r\n                options.Password.RequiredLength = 6;\r\n                options.Password.RequireLowercase = true;\r\n                options.Password.RequireNonAlphanumeric = true;\r\n                options.Password.RequireUppercase = true;\r\n                options.User.RequireUniqueEmail = true;\r\n                options.User.AllowedUserNameCharacters = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ@-._0123456789\";\r\n                options.Lockout.MaxFailedAccessAttempts = 5;\r\n                options.Lockout.AllowedForNewUsers = true;\r\n                options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);\r\n                options.Tokens.ProviderMap.Add(\"Default\", new TokenProviderDescriptor(typeof(IUserTwoFactorTokenProvider<ApplicationUser>)));\r\n            })\r\n            .AddEntityFrameworkStores<OurDataContext>()\r\n            .AddDefaultTokenProviders();\r\n\r\n        services.Configure<DataProtectionTokenProviderOptions>(options =>\r\n        {\r\n            options.Name = \"Default\";\r\n            var tokenDurationActivationLinkString = ConfigurationManager.Instance[\"TokenDurationInHoursOfActivationAccountLink\"];\r\n            var tokenDurationActivationLink = double.Parse(tokenDurationActivationLinkString);\r\n            options.TokenLifespan = TimeSpan.FromHours(tokenDurationActivationLink);\r\n        });\r\n\r\n```\r\n\r\nWhen sending the email with the link to confirm the user email, the token is fetched this way:\r\n\r\n```csharp\r\nvar token = await userContext.GenerateEmailConfirmationTokenAsync(user);\r\n```\r\n\r\nThe link works fine but even though the duration validation of the link is set to 72 hours in production. The link appeared invalid.\r\n\r\nWhen confirming the email (following the user clicking on link), the controller in charge of doing that, is basically leveraging the `ConfirmEmailAsync` method:\r\n\r\n```csharp\r\n var result = await UserManager.ConfirmEmailAsync(user, token);\r\n```\r\n\r\nTurns out that `result.Succeeded` is `true` during one or two hours (the duration is actually a bit random, sometimes it returns `false` after only 45 minutes without any change or modifications on the user) and then starts to return `false`.\r\n\r\nI inspected what `ConfirmEmailAsync` was doing, in essence:\r\n\r\n```csharp\r\n\r\npublic virtual async Task<IdentityResult> ConfirmEmailAsync(\r\n  TUser user,\r\n  string token)\r\n{\r\n  this.ThrowIfDisposed();\r\n  IUserEmailStore<TUser> store = this.GetEmailStore(true);\r\n  if ((object) user == null)\r\n    throw new ArgumentNullException(nameof (user));\r\n  if (!await this.VerifyUserTokenAsync(user, this.Options.Tokens.EmailConfirmationTokenProvider, \"EmailConfirmation\", token))\r\n    return IdentityResult.Failed(this.ErrorDescriber.InvalidToken());\r\n  await store.SetEmailConfirmedAsync(user, true, this.CancellationToken);\r\n  return await this.UpdateUserAsync(user);\r\n}\r\n```\r\n\r\nand `VerifyUserTokenAsync` is implemented as such:\r\n\r\n```csharp\r\npublic virtual async Task<bool> VerifyUserTokenAsync(\r\n  TUser user,\r\n  string tokenProvider,\r\n  string purpose,\r\n  string token)\r\n{\r\n  UserManager<TUser> manager = this;\r\n  manager.ThrowIfDisposed();\r\n  if ((object) user == null)\r\n    throw new ArgumentNullException(nameof (user));\r\n  if (tokenProvider == null)\r\n    throw new ArgumentNullException(nameof (tokenProvider));\r\n  if (!manager._tokenProviders.ContainsKey(tokenProvider))\r\n    throw new NotSupportedException(Microsoft.Extensions.Identity.Core.Resources.FormatNoTokenProvider((object) nameof (TUser), (object) tokenProvider));\r\n  bool result = await manager._tokenProviders[tokenProvider].ValidateAsync(purpose, token, manager, user);\r\n  if (!result)\r\n  {\r\n    ILogger logger = manager.Logger;\r\n    EventId eventId = (EventId) 9;\r\n    object obj = (object) purpose;\r\n    string userIdAsync = await manager.GetUserIdAsync(user);\r\n    logger.LogWarning(eventId, \"VerifyUserTokenAsync() failed with purpose: {purpose} for user {userId}.\", obj, (object) userIdAsync);\r\n    logger = (ILogger) null;\r\n    eventId = new EventId();\r\n    obj = (object) null;\r\n  }\r\n  return result;\r\n}\r\n```\r\n\r\nBy reflection I inspected that the `manager._tokenProviders[tokenProvider]` is of the type `DataProtectorTokenProvider<ApplicationUser>` hence its implementation of `ValidateAsync` is almost the actual meat of our problem:\r\n\r\n```csharp\r\n    public virtual async Task<bool> ValidateAsync(\r\n      string purpose,\r\n      string token,\r\n      UserManager<TUser> manager,\r\n      TUser user)\r\n    {\r\n      try\r\n      {\r\n        using (BinaryReader reader = new MemoryStream(this.Protector.Unprotect(Convert.FromBase64String(token))).CreateReader())\r\n        {\r\n          if (reader.ReadDateTimeOffset() + this.Options.TokenLifespan < DateTimeOffset.UtcNow)\r\n            return false;\r\n          string userId = reader.ReadString();\r\n          if (userId != await manager.GetUserIdAsync(user) || !string.Equals(reader.ReadString(), purpose))\r\n            return false;\r\n          string str1 = reader.ReadString();\r\n          if (reader.PeekChar() != -1)\r\n            return false;\r\n          if (!manager.SupportsUserSecurityStamp)\r\n            return str1 == \"\";\r\n          string str = str1;\r\n          return str == await manager.GetSecurityStampAsync(user);\r\n        }\r\n      }\r\n      catch\r\n      {\r\n      }\r\n      return false;\r\n    }\r\n```\r\n\r\nAfter many trials it seems that the issue is that at some point for **the very same token**, the line:\r\n\r\n```csharp\r\nthis.Protector.Unprotect(Convert.FromBase64String(token))\r\n```\r\n\r\nfails at some point in time.\r\n\r\nAnd this is way before even checking that the token contains a date time offset which is expired or not in regard to `UtcNow`.\r\n\r\n[The implementation of `Protector.Unprotect` is one provided by the `KeyRingBasedDataProtector` class](https://github.com/aspnet/AspNetCore/blob/416d8cd1ea098eb643b4258247af3448f5545c32/src/DataProtection/DataProtection/src/KeyManagement/KeyRingBasedDataProtector.cs#L182) and is a bit Voodoo to me.\r\n\r\nHowever it seems that the error occurs precisely [here](https://github.com/aspnet/AspNetCore/blob/416d8cd1ea098eb643b4258247af3448f5545c32/src/DataProtection/DataProtection/src/KeyManagement/KeyRingBasedDataProtector.cs#L244):\r\n\r\n```csharp\r\nIAuthenticatedEncryptor encryptorByKeyId = currentKeyRing.GetAuthenticatedEncryptorByKeyId(guid, out isRevoked);\r\nif (encryptorByKeyId == null)\r\n{\r\n    this._logger.KeyWasNotFoundInTheKeyRingUnprotectOperationCannotProceed(guid);\r\n    throw Error.Common_KeyNotFound(guid);\r\n}\r\n\r\n```\r\n\r\nConsidering that this is something I am not supposed to touch (internal classes of Identity) I am wondering why the same token would sometimes fail to be unprotected after a certain duration. It seems really voodoo to me. The only thing that seems not deterministic is in the [`KeyRingProvider`](https://github.com/aspnet/AspNetCore/blob/416d8cd1ea098eb643b4258247af3448f5545c32/src/DataProtection/DataProtection/src/KeyManagement/KeyRingProvider.cs#L142) but I am too sure how this is related to my issue.\r\n\r\n\r\n"},"comment":{"url":"https://api.github.com/repos/aspnet/AspNetCore/issues/comments/494387828","html_url":"https://github.com/aspnet/AspNetCore/issues/10399#issuecomment-494387828","issue_url":"https://api.github.com/repos/aspnet/AspNetCore/issues/10399","id":494387828,"node_id":"MDEyOklzc3VlQ29tbWVudDQ5NDM4NzgyOA==","user":{"login":"blowdart","id":796298,"node_id":"MDQ6VXNlcjc5NjI5OA==","avatar_url":"https://avatars2.githubusercontent.com/u/796298?v=4","gravatar_id":"","url":"https://api.github.com/users/blowdart","html_url":"https://github.com/blowdart","followers_url":"https://api.github.com/users/blowdart/followers","following_url":"https://api.github.com/users/blowdart/following{/other_user}","gists_url":"https://api.github.com/users/blowdart/gists{/gist_id}","starred_url":"https://api.github.com/users/blowdart/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/blowdart/subscriptions","organizations_url":"https://api.github.com/users/blowdart/orgs","repos_url":"https://api.github.com/users/blowdart/repos","events_url":"https://api.github.com/users/blowdart/events{/privacy}","received_events_url":"https://api.github.com/users/blowdart/received_events","type":"User","site_admin":false},"created_at":"2019-05-21T13:20:24Z","updated_at":"2019-05-21T13:20:24Z","author_association":"MEMBER","body":"Lack of logs is interesting, because the startup logs definitely contain config information.\r\n\r\nDid you configure IIS for dataprotection, as detailed in the [hosting documentation](https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/iis/index?view=aspnetcore-2.2#data-protection)? "}}
{"actor":{"display_login":"blowdart"}}