Closed vankampenp closed 4 years ago
This is not the expected behaviour at all, which is very weird. Are you setting a static data protection app key here?
We're closing this issue as no response or updates have been provided in a timely manner. If you have more details and are encountering this issue please add a new reply and re-open the issue.
Sorry for the delay. Yes, I am using a state data protection app key, which is generated if it is not there. Will it work if I copy the key from one to the other?
@blowdart I'm running into the exact same problem. Should I open a new issue for this since this one is closed?
Thanks, Jennifer
@jbartolome Let's reopen this one.
Can I get the startup.cs, with any secrets removed as a starting point?
Here is mine:
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.EntityFrameworkCore;
using System.IO;
using System.Linq;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using DTOWEB.Areas.api.Models;
using DTOWEB.Infrastructure;
using DTOWEBInfra;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using NLog;
using WebMarkupMin.AspNetCore2;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.AspNetCore.SignalR;
using Microsoft.IdentityModel.Tokens;
using Joonasw.AspNetCore.SecurityHeaders;
using Microsoft.OpenApi.Models;
using Utility = DTOWEBInfra.Utility;
namespace DTOWEB
{
//confirm e-mail should have a longer duration
//from https://github.com/aspnet/Identity/issues/859
public class ConfirmEmailDataProtectorTokenProvider<TUser> : DataProtectorTokenProvider<TUser> where TUser : class
{
public ConfirmEmailDataProtectorTokenProvider(IDataProtectionProvider dataProtectionProvider, IOptions<ConfirmEmailDataProtectionTokenProviderOptions> options) : base(dataProtectionProvider, options)
{
}
}
public class ConfirmEmailDataProtectionTokenProviderOptions : DataProtectionTokenProviderOptions { }
public class Startup
{
private const string EmailConfirmationTokenProviderName = "ConfirmEmail";
public Startup(IConfiguration configuration)
{
Configuration = configuration;
var ver = Assembly.GetEntryAssembly().GetName().Version.ToString();
var dir = Environment.CurrentDirectory;
var sql = Configuration["AppSettings:sql"];
//create the key, zet het in {production/development}-configurationstring
var dbConnectionString = Configuration[$"settings:{sql}"];
string[] hosts = Directory.GetCurrentDirectory().Split('\\');
var host = hosts[hosts.Length - 1] == "httpdocs" ? hosts[hosts.Length - 2] : hosts[hosts.Length - 1];
RootLanguage rootLanguage = new RootLanguage();
Configuration.GetSection("RootLanguage").Bind(rootLanguage);
RootLangBase rootLangBase = new RootLangBase();
Configuration.GetSection("RootLangBase").Bind(rootLangBase);
var appSettings = new AppSettings();
Configuration.GetSection("AppSettings").Bind(appSettings);
;
Config.Settings = new Config
{
Environment = Configuration["AppSettings:environment"].ToLower(),
DropDirectory = Path.Combine(dir, "logs"),
DebugLog = Utility.ToInt(Configuration["AppSettings:debugLog"]),
MachineName = host,
DtoVersion = ver,
ConfigurationString = dbConnectionString,
MaxFailedAccessAttempts = Utility.ToInt(Configuration["AppSettings:MaxFailedAccessAttempts"]),
AppSettings = appSettings,
LanguageNl = rootLanguage.LanguageNl,
LanguageEn = rootLanguage.LanguageEn,
LangBaseNl = rootLangBase.LanguageNl,
LangBaseEn = rootLangBase.LanguageEn,
SiteLanguage = Configuration["AppSettings:siteLanguageId"] == "en" ? rootLanguage.LanguageEn : rootLanguage.LanguageNl,
SiteDto = Configuration["AppSettings:siteLanguageId"] == "nl" ? Configuration["AppSettings:siteDto"] : Configuration["AppSettings:siteDtoEn"]
};
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
//antiforgery working:
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Config.Settings.DropDirectory));
//// Adds services required for using options.
services.AddOptions();
services.AddDistributedMemoryCache(); // Adds a default in-memory implementation of IDistributedCache
services.AddSingleton(Configuration);
// Register the IConfiguration instance which AppSettings binds against.
services.Configure<AppSettings>(Configuration.GetSection("AppSettings"));
services.Configure<RootLanguage>(Configuration.GetSection("RootLanguage"));
services.AddWebMarkupMin(options =>
{
options.AllowMinificationInDevelopmentEnvironment = true;
options.AllowCompressionInDevelopmentEnvironment = true;
})
.AddHtmlMinification()
.AddXmlMinification();
services.AddDbContext<DtoIdentityDbContext>(options =>
options.UseSqlServer(Config.EntityConnectionString));
services.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => false;
options.MinimumSameSitePolicy = Microsoft.AspNetCore.Http.SameSiteMode.None;
});
//add class to inject the cookies in to the logged in user
services.AddScoped<IUserClaimsPrincipalFactory<DtoUser>, DtoClaimsPrincipalFactory>();
services.AddAuthorization(options =>
{
//only managers allowed
options.AddPolicy("ManagersOnly", policy =>
{
policy.RequireClaim(DtoClaimTypes.IsManager, "true");
policy.RequireRole(DtoRoles.Examiner);
policy.RequireAuthenticatedUser();
});
options.AddPolicy("ReadWriteOnly", policy =>
{
policy.RequireClaim(DtoClaimTypes.IsReadWrite, "true");
policy.RequireRole(DtoRoles.Examiner);
policy.RequireAuthenticatedUser();
});
options.AddPolicy("EpdUser", policy => policy.RequireClaim(DtoClaimTypes.EpdUser, "true"));
});
services.AddIdentity<DtoUser, IdentityRole>(options =>
// Password settings
{
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = true;
// Lockout settings
options.Lockout.DefaultLockoutTimeSpan =
TimeSpan.FromMinutes(Utility.ToInt(Configuration["AppSettings:DefaultLockoutTimeSpan"]));
options.Lockout.MaxFailedAccessAttempts =
Utility.ToInt(Configuration["AppSettings:MaxFailedAccessAttempts"]);
// User settings
options.User.RequireUniqueEmail = true;
options.Tokens.EmailConfirmationTokenProvider = EmailConfirmationTokenProviderName;
})
.AddEntityFrameworkStores<DtoIdentityDbContext>()
.AddDefaultTokenProviders();
services.ConfigureApplicationCookie(options =>
{
options.AccessDeniedPath = "/index?relogin=1";
options.ExpireTimeSpan =
TimeSpan.FromMinutes(Utility.ToInt(Configuration["AppSettings:ExpireTimeSpan"]));
options.LoginPath = "/";
options.SlidingExpiration = true;
options.LoginPath = "/globalerr";
options.LogoutPath = "/";
options.AccessDeniedPath = "/globalerr";
options.Cookie = new CookieBuilder
{
Name = "Testmij",
HttpOnly = true,
SecurePolicy = CookieSecurePolicy.SameAsRequest,
};
});
if (Configuration.GetChildren().Any(x => x.Key == "Authentication"))
{
services.AddAuthentication()
.AddFacebook(options =>
{
options.AppId = Configuration["Authentication:Facebook:AppId"];
options.AppSecret =Configuration["Authentication:Facebook:AppSecret"];
})
.AddTwitter(options =>
{
options.ConsumerKey =Configuration["Authentication:Twitter:ConsumerKey"];
options.ConsumerSecret =
Configuration["Authentication:Twitter:ConsumerSecret"];
})
.AddGoogle(options =>
{
options.ClientId = Configuration["Authentication:Google:ClientId"];
options.ClientSecret = Configuration["Authentication:Google:ClientSecret"];
});
}
services.AddAuthentication().AddJwtBearer(options =>
{
options.RequireHttpsMetadata = true;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidIssuer = Configuration["JwtAuthentication:ValidAudience"],
ValidAudience = Configuration["JwtAuthentication:ValidIssuer"],
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
IssuerSigningKey =
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration["JwtAuthentication:SecurityKey"])),
};
});
services.AddHttpClient();
services.AddHttpClient("zorgmail", c =>
{
c.BaseAddress = new Uri(Configuration["zorgmail:url"]);
c.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("text/xml"));
});
services.AddSignalR(); //Communication with the client
//Get who the owner is when creating the connection, with a custom provider (OwnerIdProvider)
services.AddSingleton<IUserIdProvider, OwnerIdProvider>(); //register the custom userId provider for owners
services.Configure<ConfirmEmailDataProtectionTokenProviderOptions>(options =>
{
options.TokenLifespan =
TimeSpan.FromDays(Utility.ToInt(Configuration["AppSettings:EmailTokenLifespanDays"]));
});
services.Configure<JwtAuthentication>(Configuration.GetSection("JwtAuthentication"));
//add the hosted background service and queue
services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
services.AddHostedService<QueuedHostedService>();
// Add MVC services to the services container.
services.AddMvc(options =>
{
options.OutputFormatters.Add(new XmlSerializerOutputFormatter());
options.EnableEndpointRouting = false; //DTO-255 https://github.com/aspnet/AspNetCore/issues/6415
options.Filters.Add<OperationCancelledExceptionFilterAttribute>(); //globally handle cancellation
options.Conventions.Add(new ApiExplorerGroupPerVersionConvention()); //ApiExplorerGroupPerVersionConvention.cs used by SwashBuckle
})
.AddXmlSerializerFormatters()
.AddXmlDataContractSerializerFormatters()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
string xmlPath;
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "EPD V1", Version = "v1", Description = "EPD API gebruikt door de SOAP layer" });
c.SwaggerDoc("v2", new OpenApiInfo { Title = "EPD V2", Version = "v2", Description = "EPD Api met JWT Tokens en TokenRefresh" });
c.DescribeAllEnumsAsStrings();
c.DescribeStringEnumsInCamelCase();
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(xmlPath);
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseAuthentication();
app.UseExceptionHandler("/globalerr/{0}");
//Register Syncfusion license
Syncfusion.Licensing.SyncfusionLicenseProvider.RegisterLicense(Configuration["Syncfusion:license"]);
if (!env.IsDevelopment())
{
app.UseHsts(new HstsOptions(TimeSpan.FromDays(366), includeSubDomains: false, preload: false));
var options = new RewriteOptions()
.AddRedirectToHttpsPermanent();
app.UseRewriter(options);
}
else
{
app.UseHttpsRedirection();
}
//https://securityheaders.com :
app.UseCsp(csp =>
{ //https://github.com/juunas11/aspnetcore-security-headers
// Allow JavaScript from:
csp.AllowScripts
.FromSelf() //This domain
.From("www.googletagmanager.com")
.From("www.google-analytics.com")
.From("embed.tawk.to")
.From("cdn.jsdelivr.net")
.From("code.jquery.com")
.From("www.islonline.com");
// CSS allowed from:
csp.AllowStyles
.FromSelf()
.From("cdn.jsdelivr.net")
.From("fonts.googleapis.com")
.From("maxcdn.bootstrapcdn.com")
.AllowUnsafeInline();
csp.AllowFonts
.From("fonts.gstatic.com")
.From("static-v.tawk.to");
csp.AllowFrames
.From("va.tawk.to");
csp.OnSendingHeader = context =>
{
context.ShouldNotSend = context.HttpContext.Request.Path.StartsWithSegments("/api") || context.HttpContext.Request.Path.StartsWithSegments("/css")
|| context.HttpContext.Request.Path.StartsWithSegments("/js");
return Task.CompletedTask;
};
});
app.Use(async (context, next) =>
{
//set standard security headers
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "ALLOW-FROM datec.nl va.tawk.to");
context.Response.Headers.Remove("x-powered-by"); //doet het niet
context.Response.Headers.Add(
"Referrer-Policy", "strict-origin-when-cross-origin"); //only send referrers when going to our own site on https
context.Response.Headers.Add(
"Feature-Policy", "geolocation 'none'; camera 'none'; microphone 'none' "); //we do not need features
await next.Invoke();
});
if (Configuration["AppSettings:UseWebMarkupMin"] == "true")
{
app.UseWebMarkupMin();
}
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = context =>
{
const int durationInSeconds = 15778463; // 6 months //31556926; //1 year
context.Context.Response.Headers.Append("Cache-Control", $"public, max-age={durationInSeconds}");
}
});
app.UseCookiePolicy();
app.UseMvc(routes =>
{
routes.MapRoute(
"online",
"{area:exists}/{action=Index}",
new { controller = "Home" });
routes.MapRoute(
"areas",
"{area:exists}/{controller}/{action=Index}");
routes.MapRoute(
"base",
"{action}",
new { controller = "Base", action = "Index" });
});
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v2/swagger.json", "Testmij api V2");
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Testmij api V1");
});
app.UseSignalR(route =>
{
route.MapHub<StatusHub>("/statushub");
});
try
{
StartupDataProtection instance = ActivatorUtilities.CreateInstance<StartupDataProtection>(app.ApplicationServices);
//added as recommended here: https://github.com/aspnet/DataProtection/issues/233
instance.Init();
}
catch (Exception ex)
{
Logger logger = LogManager.GetCurrentClassLogger();
logger.Fatal(ex);
}
}
}
}
@vankampenp Did you try setting a static application name? I want to rule out the easy stuff first
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Config.Settings.DropDirectory));
.SetApplicationName("MyStaticAppName");
You shouldn't need StartupDataProtection instance = ActivatorUtilities.CreateInstance<StartupDataProtection>(app.ApplicationServices);
any more unless you're on 1.x which went out of support this month. If that's the case please consider upgrading to 2.1 or 2.2 as quickly as you can.
Thanks Barry. I am on 2.2. (2.2.6 after the security advisory yesterday). I will give that a try. The part was inserted in 1.1 after we had some issues. I tried removing it later, but it still occurred. I will report back whether the static application name helps eliminating the GeneratePasswordResetToken issue.
@blowdart I removed the CreateInstance, and just added the code above in ConfigureServices, with my own application name. I posted identical code to the two sites. Unfortunately, the issue still occurs, the token is not recognized when created on site A, for use on site B.
This is so weird. OK, we'll investigate some more.
This is the code for admin to reset a user password:
```
/// <summary>
/// Request to reset the password of a user
/// </summary>
/// <param name="examiner"></param>
[HttpPost]
public async Task<string[]> AdminResetPwd([FromBody] DtoExaminer examiner)
{
DtoUser user = await _userManager.FindByNameAsync(examiner.Examiner);
await _userManager.AddToRoleAsync(user, DtoRoles.Examiner);
string site = Url.Action("", "home", new {Area = "online"}, HttpContext.Request.Scheme);
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var err = await UserMails.SendResetEmail(token,site,user.LanguageId,user.DisplayName,user.Email);
if (err != "") return new[] { "messagebad", err };
TempCookie("MessageGood", "Een reset email is gestuurd");
return new[] { "/online/admin?tab=1" };
}
And this is where the link points to:
/// <summary>
/// called from the link in the email,
/// first verify if the token is still valid
/// if so redirect to Reset
/// </summary>
/// <param name="token"></param>
/// <param name="email"></param>
[AllowAnonymous]
[HttpGet]
public async Task<IActionResult> ResetToken([FromQuery] string token, [FromQuery] string email)
{
if (string.IsNullOrEmpty(email))
{
Logger.Warn("No user email for token request ");
return Redirect(Url.Action(nameof(ForgotPassword).ToLower(), "account", new { Area = "online", isInvalidToken = true }, HttpContext.Request.Scheme)); //try another time
}
email = email.Clean();
Logger.Warn("User {0} is resetting the password from the token ", email);
try
{
DtoUser user = await _userManager.FindByEmailAsync(email);
if (user == null)
{
Logger.Warn("Invalid user email for token request '{0}'", email);
return Redirect(Url.Action(nameof(ForgotPassword).ToLower(), "account", new { Area = "online", isInvalidToken = true }, HttpContext.Request.Scheme)); //try another time
}
var userAgent = Request.Headers["User-Agent"].ToString();
if (string.IsNullOrEmpty(userAgent)) Logger.Error("User-Agent empty");
var ua = new UserAgent(userAgent ?? "");
var userDetails = new
{
User = email,
Host = HttpContext.Request.Host,
Browser = ua.Browser.Name,
BrowserVersion = ua.Browser.Version,
OS = ua.Os.Name,
OSversion = ua.Os.Version,
siteVersion = Config.Settings.DtoVersion
};
bool success = await _userManager.VerifyUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider, "ResetPassword", token);
if (!success)
{
Logger.Warn("Invalid Token receved for '{0}'", userDetails);
return Redirect(Url.Action(nameof(ForgotPassword).ToLower(), "account", new { Area = "online", isInvalidToken = true }, HttpContext.Request.Scheme)); //try another time
}
Logger.Trace("Received Token for {0}", userDetails);
}
catch (Exception e)
{
Logger.Error(e);
return LocalRedirect(Url.Action(nameof(HomeController.Index).ToLower(), "home", new { Area = "online", message = _siteLanguage.UnknownError, messageIsGood = false }));
}
return LocalRedirect(Url.Action(nameof(Reset).ToLower(), "account", new
{
Area = "online",
message = _siteLanguage.AccountRedirect,
messageIsGood = true,
token,
email
}));
}
userManager.VerifyUserTokenAsync returns false
Can you slim this down to the smallest repo possible for Hao to look at?
@blowdart I will try to do this next week
@HaoK Attached the repo as promised. This repo demonstrates the issue that when using a different domain url, the token no longer works.
The demo simulates the process where a user registers an account, and is sent a token via email that is generated through userManager.GeneratePasswordResetTokenAsync(user). In stead of mailing the link, the link is just displayed on the site. After clicking the link, the token is verified against the user.
Launch the project using Kestrel server. Configure the host address that you use in application.json Host. Default is https://localhost:5001. On the first launch execute the migrations to populate the Identity database.
Step 1: click on Register, and enter an email address (it is not used to email, just as the user name and registered email address)
Step 2: The link is displayed (as if you are in your email app). Click the link.
Step 3: your token is verified, if correct, you can now set your password (Upper/Lower case, numeric and non-alphanumeric character). Press Set Password. You are now logged in.
You can change the password by clicking on your email address in the home page, or by logging out and clicking on Register again.
Step 4: Stop the app, and now copy the repo in to a new folder. Do not change anything. Start the app with a different port, for example by starting it using IIS Express this time. If needed, you can change the port number in this second repo.
Step 5: start the original repo and the copied repo. Both have the Host configured to point to the first repo executable.
Step 6: In the second repo login with your registered email address. Note that this is possible because both repo's use the same underlying data store.
Step 7: In the second repo click on your email address (Forgot Password), to reset your password.
Step 8: You get a link with the generated token, pointing to the url of the first repo. Click it.
Step 9: Notice you are redirected to the first repo, your token is checked, but it returns an error. The app shows the message "Invalid Token".
@blowdart can you confirm what's the default behavior of dataprotection? This app isn't doing any configuration of dataprotection, so I'm assuming the issue is that copying the app results in differences in the two apps dataprotection keys since they aren't configured to share the same keys.
Apps are isolated by default, even if they share the keyring.
Okay so everything is working by design here then.
@vankampenp If these are indeed separate instances on separate domain names you need to configure the application name to be a fixed value in all instances;
public void ConfigureServices(IServiceCollection services)
{
services.AddDataProtection()
.SetApplicationName("example");
}
Thanks @blowdart, After making the sample, I found that. But still no luck. But by also persisting the keys to the same UNC location did the trick. services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(Configuration["AppSettings:keydir"])) .SetApplicationName(Configuration["AppSettings:keyname"]);
This now works!
Describe the bug
Not sure it is a bug or a missing feature. We have two identical websites using the same underlying database. The only difference is the language. For example www.example.fr and www.example.nl
I would like to have one admin location, generating users, or resetting passwords. However, when the token is created on one web site, it does not work on the other.
To Reproduce
Steps to reproduce the behavior:
Using ASP.NET Core 2.2
Run this code on domain: www.example.fr
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
Construct a link for the token including the second domain: var link = "https://www.example.nl/account/resettoken?token=token&email=email Email this link to the user
On the other domain (/www.example.nl), when the link is clicked, get the user:
var user = await _userManager.FindByEmailAsync(email);
(Note: the database is the same, so the user is known here too)and verify the token:
bool success = await _userManager.VerifyUserTokenAsync(user, Options.Tokens.PasswordResetTokenProvider, "ResetPassword", token);
The result is an invalid token. If I take the link form the email and change the domain www.example.nl to www.example.fr, the token is validated successfully
Expected behavior
I did not expect the current domain on which the token is generated to be part of the check in the token.
Is this intended behavior? If so would it be possible to generate a verification token for another domain?
Thanks, Pieter