I have a Blazor WASM Hosted solution (Server, Client, Models). Obviously the Client side is blazor components that are posting to the Server API Controllers. However, I am looking to have some routes be run specifically on the Server in an MVC format. Example being profile management like changing password or upgrading membership options. Things that are most secure being served on the server rather then hosted in the client. However, I am not able to get this to work. I am assuming i am missing something when it comes to setting up the MVC routing on the Server application.
Here is my Server Startup.cs
public void ConfigureServices(IServiceCollection services)
{
//Register the Datacontext and Connection String
services.AddDbContext<DataContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
services.AddDatabaseDeveloperPageExceptionFilter();
//Sets up the default Asp.net core Identity Screens - Use Identity Scaffolding to override defaults
services.AddDefaultIdentity<ApplicationUser>( options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequiredUniqueChars = 0;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.User.RequireUniqueEmail = true;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<DataContext>();
//Associates the User to Context with Identity
services.AddIdentityServer()
.AddApiAuthorization<ApplicationUser, DataContext>( options =>
{
options.IdentityResources["openid"].UserClaims.Add(JwtClaimTypes.Role);
options.ApiResources.Single().UserClaims.Add(JwtClaimTypes.Role);
});
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove(JwtClaimTypes.Role);
//Adds authentication handler
services.AddAuthentication().AddIdentityServerJwt();
services.AddHttpContextAccessor();
//Register Radzen Services
services.AddScoped<DialogService>();
services.AddScoped<NotificationService>();
services.AddScoped<TooltipService>();
services.AddScoped<ContextMenuService>();
services.AddControllersWithViews();
services.AddRazorPages();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DataContext dataContext)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseMigrationsEndPoint();
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
//AutoMigrates data
dataContext.Database.Migrate();
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseSerilogIngestion();
app.UseSerilogRequestLogging();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
endpoints.MapControllers();
endpoints.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapFallbackToFile("index.html");
});
}
Here is the Controller that I am trying to hit on the Server
[ApiController]
[Route("[controller]")]
public class ProfileController : Controller
{
private UserManager<ApplicationUser> _userManager;
private ILogger<ProfileController> _logger;
private readonly SignInManager<ApplicationUser> _signInManager;
public ProfileController(ILogger<ProfileController> logger, UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager)
{
_userManager = userManager;
_logger = logger;
_signInManager = signInManager;
}
public IActionResult Index()
{
return View();
}
[HttpGet]
[Route("ChangePassword")]
public IActionResult ChangePassword()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
[Route("ChangePassword/{Id?}")]
public async Task<ActionResult> ChangePassword(ChangePasswordViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
string userId = "";
if (User.Identity.IsAuthenticated)
userId = User.FindFirst(ClaimTypes.NameIdentifier).Value;
var user = await _userManager.FindByIdAsync(userId);
var result = await _userManager.ChangePasswordAsync(user, model.OldPassword, model.NewPassword);
if (result.Succeeded)
{
await _signInManager.SignInAsync(user, isPersistent: false);
return Redirect("/");
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(error.Code, error.Description);
}
return View(model);
}
}
I can navigate to the ChangePassword page via the following NavLink in my Client component
<NavLink class="btn btn-outline-primary" href="Profile/ChangePassword">Change password</NavLink>
However, when I submit the form I get the following error
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.13","title":"Unsupported Media Type","status":415,"traceId":"00-4d07a26df9047d4bb56223728e7e2148-923c11953b1a754e-00"}
Here is the form being posted
@using (Html.BeginForm("ChangePassword", "Profile", FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
{
@Html.AntiForgeryToken()
<h4>Change Password Form</h4>
<hr />
@Html.ValidationSummary(true, "", new { @class = "text-danger" })
<div class="form-group">
@Html.LabelFor(m => m.OldPassword, new { @class = "col-md-2 control-label" })
<div class="col-md-10">
@Html.PasswordFor(m => m.OldPassword, new { @class = "form-control" })
@Html.ValidationMessageFor(model => model.OldPassword, "", new { @class = "text-danger" })
</div>
</div>
<div class="form-group">
@Html.LabelFor(m => m.NewPassword, new { @class = "col-md-2 control-label" })
<div class="col-md-10">
@Html.PasswordFor(m => m.NewPassword, new { @class = "form-control" })
@Html.ValidationMessageFor(model => model.NewPassword, "", new { @class = "text-danger" })
</div>
</div>
<div class="form-group">
@Html.LabelFor(m => m.ConfirmPassword, new { @class = "col-md-2 control-label" })
<div class="col-md-10">
@Html.PasswordFor(m => m.ConfirmPassword, new { @class = "form-control" })
@Html.ValidationMessageFor(model => model.ConfirmPassword, "", new { @class = "text-danger" })
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<input type="submit" value="Change password" class="btn btn-primary white" />
</div>
</div>
}
So this says to me I dont have this configured right and am just lucky that I am hitting the change password page.
Update: I added FromForm to the Post method which I shouldnt have to add, but that got it recognizing the post type.
[HttpPost]
[ValidateAntiForgeryToken]
[Route("ChangePassword/{Id?}")]
public async Task<ActionResult> ChangePassword([FromForm] ChangePasswordViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
string userId = "";
if (User.Identity.IsAuthenticated)
userId = User.FindFirst(ClaimTypes.NameIdentifier).Value;
var user = await _userManager.FindByIdAsync(userId);
var result = await _userManager.ChangePasswordAsync(user, model.OldPassword, model.NewPassword);
if (result.Succeeded)
{
await _signInManager.SignInAsync(user, isPersistent: false);
return Redirect("/");
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(error.Code, error.Description);
}
return View(model);
}
However, if i enter an invalid password length, when the Validation Controls should show warnings, they dont. If i submit then i get another error message in the web browser
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-8376a8e3bd02c7438e45da7337b889ce-0d0e067ffd75a944-00","errors":{"NewPassword":["The New password must be at least 6 characters long."]}}
So the MVC side of things definitely is not working correctly