I am fairly new to Angular and MVC web applications, and I am building an ASP.NET Core 2.2 MVC, Angular 9 and EF Core SPA application with the authentication and authorization features of Microsoft.AspNetCore.Identity
and Microsoft.AspNetCore.Antiforgery
XSRF cookies. My login method is originated in the client app.
In my development environment, I can log in and authenticate and use the appropriate authorization to GET, PUT, POST & DELETE. And all the verbs in my ASP.NET Core MVC api controller methods match the HTTP verbs in spelling.
Once deployed to my Windows Shared production web hosting server (uses IIS/10), I can log in, Authenticate and with appropriate Authorization GET, PUT, POST and DELETE successfully for the first 10 minutes on the website.
After about 10 minutes, when I try to update data, for a PUT I receive an HTTP 302 - Found, followed by an HTTP 405 - Method Not Allowed for an attempted Redirect to /Account/Login (Server-side).
I have already modified the web.config to eliminate the WebDAV issue, and like I said, I can perform all the HTTP methods for the first ten minutes after being signed in.
I have posted some code below, please ask if it helps to see any ClientApp http datasource methods.
Does anyone have any suggestions of things to try?
Web.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<modules>
<remove name="WebDAVModule" />
</modules>
<handlers>
<remove name="WebDAVHandler" />
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath=".\ServerApp.exe" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="InProcess" />
</system.webServer>
</location>
<connectionStrings>
<remove name="LocalSqlServer" />
</connectionStrings>
</configuration>
Startup.cs
using ...
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Antiforgery;
namespace ServerApp
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
string connectionString = ... ;
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<IdentityDataContext>();
services.AddTransient<IWebService ... >();
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
services.AddAntiforgery(options =>
{
options.HeaderName = "X-XSRF-TOKEN";
});
services.AddMvc(options =>
{
options.Filters.Add(new ValidateAntiForgeryTokenAttribute());
});
}
// to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env,
IServiceProvider services, IAntiforgery antiforgery)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
RequestPath = "",
FileProvider = new PhysicalFileProvider(
Path.Combine(Directory.GetCurrentDirectory(), "./wwwroot/app"))
});
app.UseCookiePolicy();
app.UseAuthentication();
app.Use(nextDelegate => context =>
{
string path = context.Request.Path.Value;
string[] directUrls = { "/xxxxx", "/aaaxxx", "/form", "/table" };
if (path.StartsWith("/api") || string.Equals("/", path) ||
directUrls.Any(url => path.StartsWith(url)))
{
var tokens = antiforgery.GetAndStoreTokens(context);
context.Response.Cookies.Append("XSRF-REQUEST-TOKEN",
tokens.RequestToken,
new CookieOptions()
{
HttpOnly = false,
Secure = false,
IsEssential = true,
Expires = DateTime.Now.AddMinutes(120)
});
}
return nextDelegate(context);
});
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
// used in Development only - commented out for deployment to Production
// app.UseSpa(spa =>
// {
// spa.Options.SourcePath = "../ClientApp";
// spa.UseProxyToSpaDevelopmentServer("http://localhost:4200");
// });
}
}
}
Good PUT
- HTTP Headers:
GENERAL:
Request URL: https://aaa.com/api/widgets
Request Method: PUT
Status Code: 200
Remote Address: xxx.xxx.xx.xx:443
Referrer Policy: strict-origin-when-cross-origin
RESPONSE HEADERS:
cache-control: no-cache, no-store
content-length: 815
content-type: application/json; charset=utf-8
date: Sat, 16 Apr 2022 23:32:15 GMT
pragma: no-cache
server: Microsoft-IIS/10.0
set-cookie: XSRF-REQUEST-TOKEN=CfDJ ... olg; expires=Sun, 17 Apr 2022 01:02:16 GMT; path=/; samesite=lax
strict-transport-security: max-age=2592000
x-powered-by: ASP.NET
x-powered-by-plesk: PleskWin
REQUEST HEADERS:
:authority: aaa.com
:method: PUT
:path: /api/widgets
:scheme: https
accept: application/json, text/plain, */*
accept-encoding: gzip, deflate, br
accept-language: en-US,en;q=0.9
access-key: <secret>
application-names: ClientApp,Angular
cache-control: no-cache
content-length: 815
content-type: application/json
cookie: .AspNetCore.Antiforgery.xxxxxxxxxxx=CfDJ ... Jfs; .AspNetCore.Identity.Application=CfDJ ... OqA; XSRF-REQUEST-TOKEN=CfDJ ... EuQ
origin: https://aaa.com
pragma: no-cache
referer: https://aaa.com/form/edit/57
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-origin
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36
x-xsrf-token: CfDJ ... EuQ
Response body: [has good data]
Bad PUT
- HTTP headers:
GENERAL:
Request URL: https://aaa.com/api/widgets
Request Method: PUT
Status Code: 302
Remote Address: xxx.xxx.xx.xx:443
Referrer Policy: strict-origin-when-cross-origin
RESPONSE HEADERS:
cache-control: no-cache, no-store
date: Sat, 16 Apr 2022 23:43:50 GMT
location: https://aaa.com/Account/Login?ReturnUrl=%2Fapi%2Fwidgets
pragma: no-cache
server: Microsoft-IIS/10.0
set-cookie: .AspNetCore.Antiforgery.xxxxxKXldp8=CfDJ ... rVM; path=/; samesite=strict; httponly
set-cookie: XSRF-REQUEST-TOKEN=CfDJ ... W9M; expires=Sun, 17 Apr 2022 01:13:50 GMT; path=/;
samesite=lax
strict-transport-security: max-age=2592000
x-frame-options: SAMEORIGIN
x-powered-by: ASP.NET
x-powered-by-plesk: PleskWin
REQUEST HEADERS:
:authority: aaa.com
:method: PUT
:path: /api/widgets
:scheme: https
accept: application/json, text/plain, */*
accept-encoding: gzip, deflate, br
accept-language: en-US,en;q=0.9
access-key: <coolsecret>
application-names: ClientApp,Angular
cache-control: no-cache
content-length: 815
content-type: application/json
cookie: .AspNetCore.Antiforgery.xxxxxKXldp8=CfDJ ... Jfs; .AspNetCore.Identity.Application=CfDJ ... OqA; XSRF-REQUEST-TOKEN=CfDJ ... olg
origin: https://aaa.com
pragma: no-cache
referer: https://aaa.com/form/edit/57
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-origin
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36
x-xsrf-token: CfDJ ... olg
Response body: Failed to load response data. No content available becuase this request was redirected.
Redirect to login:
GENERAL:
Request URL: https://aaa.com/Account/Login?ReturnUrl=%2Fapi%2Fwidgets
Request Method: PUT
Status Code: 405
Remote Address: xxx.xxx.xx.xx:443
Referrer Policy: strict-origin-when-cross-origin
RESPONSE HEADERS:
allow: GET, POST
date: Sat, 16 Apr 2022 23:43:50 GMT
server: Microsoft-IIS/10.0
strict-transport-security: max-age=2592000
x-powered-by: ASP.NET
x-powered-by-plesk: PleskWin
REQUEST HEADERS:
:authority: aaa.com
:method: PUT
:path: /Account/Login?ReturnUrl=%2Fapi%2Fwidgets
:scheme: https
accept: application/json, text/plain, */*
accept-encoding: gzip, deflate, br
accept-language: en-US,en;q=0.9
access-key: <coolsecret>
application-names: ClientApp,Angular
cache-control: no-cache
content-length: 815
content-type: application/json
cookie: .AspNetCore.Identity.Application=CfDJ ... OqA; .AspNetCore.Antiforgery.xxxxxxxxxxx=CfDJ ... rVM; XSRF-REQUEST-TOKEN=CfDJ ... W9M
origin: https://aaa.com
pragma: no-cache
referer: https://aaa.com/form/edit/57
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
sec-fetch-dest: empty
sec-fetch-mode: cors
sec-fetch-site: same-origin
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36
x-xsrf-token: CfDJ ... olg
Payload: ReturnUrl: %2Fapi%2Fwidgets
Response: This request has no response data available.
ServerApp.AccountController
namespace ServerApp.Controllers
{
[ValidateAntiForgeryToken]
public class AccountController : Controller
{
private UserManager<IdentityUser> userManager;
private SignInManager<IdentityUser> signInManager;
public AccountController(UserManager<IdentityUser> userMgr,
SignInManager<IdentityUser> signInMgr)
{
userManager = userMgr;
signInManager = signInMgr;
}
[HttpGet]
public IActionResult Login(string returnUrl)
{
ViewBag.returnUrl = returnUrl;
return View();
}
[HttpPost]
public async Task<IActionResult> Login(LoginViewModel creds, string returnUrl)
{
if (ModelState.IsValid)
{
if (await DoLogin(creds))
{
return Redirect(returnUrl ?? "/");
}
else
{
ModelState.AddModelError("", "Invalid username or password");
}
}
return View(creds);
}
[HttpPost]
public async Task<IActionResult> Logout(string redirectUrl)
{
await signInManager.SignOutAsync();
return Redirect(redirectUrl ?? "/");
}
[HttpPost("/api/account/login")] // login request from client posts here
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Login([FromBody] LoginViewModel creds)
{
if (ModelState.IsValid && await DoLogin(creds))
{
return Ok("true");
}
return BadRequest(); // http 400
}
// called by login from client (see above)
private async Task<bool> DoLogin(LoginViewModel creds)
{
IdentityUser user = await userManager.FindByNameAsync(creds.Name);
if (user != null)
{
await signInManager.SignOutAsync();
Microsoft.AspNetCore.Identity.SignInResult result =
await signInManager.PasswordSignInAsync(user, creds.Password, false, false);
return result.Succeeded;
}
return false;
}
[HttpPost("/api/account/logout")]
public async Task<IActionResult> Logout()
{
await signInManager.SignOutAsync();
return Ok();
}
}
public class LoginViewModel
{
[Required]
public string Name { get; set; }
[Required]
public string Password { get; set; }
}
}
ServerApp.AntiforgeryController
namespace ServerApp.Controllers
{
[ApiController]
[IgnoreAntiforgeryToken]
public class AntiForgeryController : Controller
{
private IAntiforgery _antiForgery;
public AntiForgeryController(IAntiforgery antiForgery)
{
_antiForgery = antiForgery;
}
[Route("api/antiforgery")]
[IgnoreAntiforgeryToken]
public IActionResult GenerateAntiForgeryTokens()
{
var tokens = _antiForgery.GetAndStoreTokens(HttpContext);
Response.Cookies.Append("XSRF-REQUEST-TOKEN", tokens.RequestToken, new CookieOptions
{
HttpOnly = false,
});
return Ok("true");
}
}
}
HomeController.Error
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
ServerApp.WidgetValuesController (API) HTTP methods
using Microsoft.AspNetCore.Authorization;
namespace ServerApp.Controllers {
[Route("api/widgets")]
[Authorize(Roles = "AdminRole1, AdminRole2")]
[ValidateAntiForgeryToken]
public class WidgetValuesController : Controller {
private IWebServiceWidgetRepository repository;
public WidgetValuesController(IWebServiceWidgetRepository repo)
=> repository = repo;
[HttpGet]
public object Widgets() {
return repository.GetWidgets();
}
[HttpPost]
public Widget Post([FromBody] Widget widget) {
return repository.StoreWidget(widget);
}
[HttpPut]
public Widget Put([FromBody] Widget widget)
{
return repository.UpdateWidget(widget);
}
}
}