Added SetAntiForgeryCookieAndHeader method that sets cookie token and header
This commit is contained in:
parent
67b078862e
commit
951ed05893
|
|
@ -97,5 +97,14 @@ namespace Microsoft.AspNet.Mvc
|
||||||
{
|
{
|
||||||
Validate(context, antiForgeryTokenSet.CookieToken, antiForgeryTokenSet.FormToken);
|
Validate(context, antiForgeryTokenSet.CookieToken, antiForgeryTokenSet.FormToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates and sets an anti-forgery cookie if one is not available or not valid. Also sets response headers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The HTTP context associated with the current call.</param>
|
||||||
|
public void SetCookieTokenAndHeader([NotNull] HttpContext context)
|
||||||
|
{
|
||||||
|
_worker.SetCookieTokenAndHeader(context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -102,19 +102,8 @@ namespace Microsoft.AspNet.Mvc
|
||||||
var tokenSet = GetTokens(httpContext, oldCookieToken);
|
var tokenSet = GetTokens(httpContext, oldCookieToken);
|
||||||
var newCookieToken = tokenSet.CookieToken;
|
var newCookieToken = tokenSet.CookieToken;
|
||||||
var formToken = tokenSet.FormToken;
|
var formToken = tokenSet.FormToken;
|
||||||
if (newCookieToken != null)
|
|
||||||
{
|
|
||||||
// If a new cookie was generated, persist it.
|
|
||||||
_tokenStore.SaveCookieToken(httpContext, newCookieToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_config.SuppressXFrameOptionsHeader)
|
SaveCookieTokenAndHeader(httpContext, newCookieToken);
|
||||||
{
|
|
||||||
// Adding X-Frame-Options header to prevent ClickJacking. See
|
|
||||||
// http://tools.ietf.org/html/draft-ietf-websec-x-frame-options-10
|
|
||||||
// for more information.
|
|
||||||
httpContext.Response.Headers.Set("X-Frame-Options", "SAMEORIGIN");
|
|
||||||
}
|
|
||||||
|
|
||||||
// <input type="hidden" name="__AntiForgeryToken" value="..." />
|
// <input type="hidden" name="__AntiForgeryToken" value="..." />
|
||||||
var retVal = new TagBuilder("input");
|
var retVal = new TagBuilder("input");
|
||||||
|
|
@ -143,15 +132,11 @@ namespace Microsoft.AspNet.Mvc
|
||||||
|
|
||||||
private AntiForgeryTokenSetInternal GetTokens(HttpContext httpContext, AntiForgeryToken oldCookieToken)
|
private AntiForgeryTokenSetInternal GetTokens(HttpContext httpContext, AntiForgeryToken oldCookieToken)
|
||||||
{
|
{
|
||||||
AntiForgeryToken newCookieToken = null;
|
var newCookieToken = ValidateAndGenerateNewToken(oldCookieToken);
|
||||||
if (!_validator.IsCookieTokenValid(oldCookieToken))
|
if (newCookieToken != null)
|
||||||
{
|
{
|
||||||
// Need to make sure we're always operating with a good cookie token.
|
oldCookieToken = newCookieToken;
|
||||||
oldCookieToken = newCookieToken = _generator.GenerateCookieToken();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug.Assert(_validator.IsCookieTokenValid(oldCookieToken));
|
|
||||||
|
|
||||||
var formToken = _generator.GenerateFormToken(
|
var formToken = _generator.GenerateFormToken(
|
||||||
httpContext,
|
httpContext,
|
||||||
ExtractIdentity(httpContext),
|
ExtractIdentity(httpContext),
|
||||||
|
|
@ -204,6 +189,54 @@ namespace Microsoft.AspNet.Mvc
|
||||||
deserializedFormToken);
|
deserializedFormToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates and sets an anti-forgery cookie if one is not available or not valid. Also sets response headers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The HTTP context associated with the current call.</param>
|
||||||
|
public void SetCookieTokenAndHeader([NotNull] HttpContext httpContext)
|
||||||
|
{
|
||||||
|
CheckSSLConfig(httpContext);
|
||||||
|
|
||||||
|
var oldCookieToken = GetCookieTokenNoThrow(httpContext);
|
||||||
|
var newCookieToken = ValidateAndGenerateNewToken(oldCookieToken);
|
||||||
|
|
||||||
|
SaveCookieTokenAndHeader(httpContext, newCookieToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method returns null if oldCookieToken is valid.
|
||||||
|
private AntiForgeryToken ValidateAndGenerateNewToken(AntiForgeryToken oldCookieToken)
|
||||||
|
{
|
||||||
|
if (!_validator.IsCookieTokenValid(oldCookieToken))
|
||||||
|
{
|
||||||
|
// Need to make sure we're always operating with a good cookie token.
|
||||||
|
var newCookieToken = _generator.GenerateCookieToken();
|
||||||
|
Debug.Assert(_validator.IsCookieTokenValid(newCookieToken));
|
||||||
|
return newCookieToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveCookieTokenAndHeader(
|
||||||
|
[NotNull] HttpContext httpContext,
|
||||||
|
AntiForgeryToken newCookieToken)
|
||||||
|
{
|
||||||
|
if (newCookieToken != null)
|
||||||
|
{
|
||||||
|
// Persist the new cookie if it is not null.
|
||||||
|
_tokenStore.SaveCookieToken(httpContext, newCookieToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_config.SuppressXFrameOptionsHeader)
|
||||||
|
{
|
||||||
|
// Adding X-Frame-Options header to prevent ClickJacking. See
|
||||||
|
// http://tools.ietf.org/html/draft-ietf-websec-x-frame-options-10
|
||||||
|
// for more information.
|
||||||
|
httpContext.Response.Headers.Set("X-Frame-Options", "SAMEORIGIN");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class AntiForgeryTokenSetInternal
|
private class AntiForgeryTokenSetInternal
|
||||||
{
|
{
|
||||||
public AntiForgeryToken FormToken { get; set; }
|
public AntiForgeryToken FormToken { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -561,7 +561,10 @@ namespace Microsoft.AspNet.Mvc.Razor
|
||||||
/// <returns>A<see cref="Task{HtmlString}"/> that represents the asynchronous flush operation and on
|
/// <returns>A<see cref="Task{HtmlString}"/> that represents the asynchronous flush operation and on
|
||||||
/// completion returns a <see cref="HtmlString.Empty"/>.</returns>
|
/// completion returns a <see cref="HtmlString.Empty"/>.</returns>
|
||||||
/// <remarks>The value returned is a token value that allows FlushAsync to work directly in an HTML
|
/// <remarks>The value returned is a token value that allows FlushAsync to work directly in an HTML
|
||||||
/// section. However the value does not represent the rendered content.</remarks>
|
/// section. However the value does not represent the rendered content.
|
||||||
|
/// This method also writes out headers, so any modifications to headers must be done before FulshAsync is
|
||||||
|
/// called. For example, call <see cref="SetAntiForgeryCookieAndHeader"/> to send anti-forgery cookie token
|
||||||
|
/// and X-Frame-Options header to client before this method flushes headers out. </remarks>
|
||||||
public async Task<HtmlString> FlushAsync()
|
public async Task<HtmlString> FlushAsync()
|
||||||
{
|
{
|
||||||
// If there are active writing scopes then we should throw. Cannot flush content that has the potential to
|
// If there are active writing scopes then we should throw. Cannot flush content that has the potential to
|
||||||
|
|
@ -617,6 +620,20 @@ namespace Microsoft.AspNet.Mvc.Razor
|
||||||
PageExecutionContext?.EndContext();
|
PageExecutionContext?.EndContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets anti-forgery cookie and X-Frame-Options header on the response.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="HtmlString"/> that returns a <see cref="HtmlString.Empty"/>.</returns>
|
||||||
|
/// <remarks> Call this method to send anti-forgery cookie token and X-Frame-Options header to client
|
||||||
|
/// before <see cref="FlushAsync"/> flushes the headers. </remarks>
|
||||||
|
public virtual HtmlString SetAntiForgeryCookieAndHeader()
|
||||||
|
{
|
||||||
|
var antiForgery = Context.RequestServices.GetRequiredService<AntiForgery>();
|
||||||
|
antiForgery.SetCookieTokenAndHeader(Context);
|
||||||
|
|
||||||
|
return HtmlString.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureMethodCanBeInvoked(string methodName)
|
private void EnsureMethodCanBeInvoked(string methodName)
|
||||||
{
|
{
|
||||||
if (PreviousSectionWriters == null)
|
if (PreviousSectionWriters == null)
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,29 @@ namespace Microsoft.AspNet.Mvc.Core.Test
|
||||||
context.TokenProvider.Verify();
|
context.TokenProvider.Verify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, "SAMEORIGIN")]
|
||||||
|
[InlineData(true, null)]
|
||||||
|
public void SetCookieTokenAndHeader_AddsXFrameOptionsHeader(bool suppressXFrameOptions, string expectedHeaderValue)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var options = new AntiForgeryOptions()
|
||||||
|
{
|
||||||
|
SuppressXFrameOptionsHeader = suppressXFrameOptions
|
||||||
|
};
|
||||||
|
|
||||||
|
// Genreate a new cookie.
|
||||||
|
var context = GetAntiForgeryWorkerContext(options, useOldCookie: false, isOldCookieValid: false);
|
||||||
|
var worker = GetAntiForgeryWorker(context);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
worker.SetCookieTokenAndHeader(context.HttpContext.Object);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var xFrameOptions = context.HttpContext.Object.Response.Headers["X-Frame-Options"];
|
||||||
|
Assert.Equal(expectedHeaderValue, xFrameOptions);
|
||||||
|
}
|
||||||
|
|
||||||
private AntiForgeryWorker GetAntiForgeryWorker(AntiForgeryWorkerContext context)
|
private AntiForgeryWorker GetAntiForgeryWorker(AntiForgeryWorkerContext context)
|
||||||
{
|
{
|
||||||
return new AntiForgeryWorker(
|
return new AntiForgeryWorker(
|
||||||
|
|
|
||||||
|
|
@ -234,5 +234,117 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
|
||||||
Assert.Equal("The required anti-forgery form field \"__RequestVerificationToken\" is not present.",
|
Assert.Equal("The required anti-forgery form field \"__RequestVerificationToken\" is not present.",
|
||||||
exception.ExceptionMessage);
|
exception.ExceptionMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetCookieAndHeaderBeforeFlushAsync_GeneratesCookieTokenAndHeader()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var server = TestServer.Create(_services, _app);
|
||||||
|
var client = server.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync("http://localhost/Account/FlushAsyncLogin");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var header = Assert.Single(response.Headers.GetValues("X-Frame-Options"));
|
||||||
|
Assert.Equal("SAMEORIGIN", header);
|
||||||
|
|
||||||
|
var setCookieHeader = response.Headers.GetValues("Set-Cookie").ToArray();
|
||||||
|
|
||||||
|
var cookie = Assert.Single(setCookieHeader);
|
||||||
|
Assert.True(cookie.StartsWith("__RequestVerificationToken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetCookieAndHeaderBeforeFlushAsync_PostToForm()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var server = TestServer.Create(_services, _app);
|
||||||
|
var client = server.CreateClient();
|
||||||
|
|
||||||
|
// do a get response.
|
||||||
|
var getResponse = await client.GetAsync("http://localhost/Account/FlushAsyncLogin");
|
||||||
|
var resposneBody = await getResponse.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
var formToken = AntiForgeryTestHelper.RetrieveAntiForgeryToken(resposneBody, "Account/FlushAsyncLogin");
|
||||||
|
var cookieToken = AntiForgeryTestHelper.RetrieveAntiForgeryCookie(getResponse);
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Account/FlushAsyncLogin");
|
||||||
|
request.Headers.Add("Cookie", "__RequestVerificationToken=" + cookieToken);
|
||||||
|
var nameValueCollection = new List<KeyValuePair<string, string>>
|
||||||
|
{
|
||||||
|
new KeyValuePair<string,string>("__RequestVerificationToken", formToken),
|
||||||
|
new KeyValuePair<string,string>("UserName", "test"),
|
||||||
|
new KeyValuePair<string,string>("Password", "password"),
|
||||||
|
};
|
||||||
|
|
||||||
|
request.Content = new FormUrlEncodedContent(nameValueCollection);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("OK", await response.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushAsyncBeforeAntiForgery_CookieAndHeaderNotInResponse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var server = TestServer.Create(_services, _app);
|
||||||
|
var client = server.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync("http://localhost/Account/FlushWithoutUpdatingHeader");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
IEnumerable<string> returnList;
|
||||||
|
Assert.False(response.Headers.TryGetValues("Set-Cookie", out returnList));
|
||||||
|
Assert.False(response.Headers.TryGetValues("X-Frame-Options", out returnList));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FlushAsyncBeforeAntiForgery_PostToForm()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var server = TestServer.Create(_services, _app);
|
||||||
|
var client = server.CreateClient();
|
||||||
|
|
||||||
|
// do a get response.
|
||||||
|
var getResponse = await client.GetAsync("http://localhost/Account/FlushWithoutUpdatingHeader");
|
||||||
|
var resposneBody = await getResponse.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
// Assert - 1
|
||||||
|
IEnumerable<string> returnList;
|
||||||
|
Assert.False(getResponse.Headers.TryGetValues("Set-Cookie", out returnList));
|
||||||
|
Assert.False(getResponse.Headers.TryGetValues("X-Frame-Options", out returnList));
|
||||||
|
|
||||||
|
var formToken = AntiForgeryTestHelper.RetrieveAntiForgeryToken(
|
||||||
|
resposneBody,
|
||||||
|
"Account/FlushWithoutUpdatingHeader");
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"http://localhost/Account/FlushWithoutUpdatingHeader");
|
||||||
|
|
||||||
|
var nameValueCollection = new List<KeyValuePair<string, string>>
|
||||||
|
{
|
||||||
|
new KeyValuePair<string,string>("__RequestVerificationToken", formToken),
|
||||||
|
new KeyValuePair<string,string>("UserName", "test"),
|
||||||
|
new KeyValuePair<string,string>("Password", "password"),
|
||||||
|
};
|
||||||
|
|
||||||
|
request.Content = new FormUrlEncodedContent(nameValueCollection);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
|
||||||
|
// Assert - 2
|
||||||
|
var exception = response.GetServerException();
|
||||||
|
Assert.Equal("The required anti-forgery cookie \"__RequestVerificationToken\" is not present.",
|
||||||
|
exception.ExceptionMessage);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -31,5 +31,41 @@ namespace AntiForgeryWebSite
|
||||||
{
|
{
|
||||||
return "OK";
|
return "OK";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: /Account/FlushAsyncLogin
|
||||||
|
[AllowAnonymous]
|
||||||
|
public ActionResult FlushAsyncLogin(string returnUrl = null)
|
||||||
|
{
|
||||||
|
ViewBag.ReturnUrl = returnUrl;
|
||||||
|
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: /Account/FlushAsyncLogin
|
||||||
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public string FlushAsyncLogin(LoginViewModel model)
|
||||||
|
{
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /Account/FlushWithoutUpdatingHeader
|
||||||
|
[AllowAnonymous]
|
||||||
|
public ActionResult FlushWithoutUpdatingHeader(string returnUrl = null)
|
||||||
|
{
|
||||||
|
ViewBag.ReturnUrl = returnUrl;
|
||||||
|
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: /Account/FlushWithoutUpdatingHeader
|
||||||
|
[HttpPost]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public string FlushWithoutUpdatingHeader(LoginViewModel model)
|
||||||
|
{
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
@model AntiForgeryWebSite.LoginViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Title = "Log in";
|
||||||
|
Layout = "/Views/Shared/_FlushAsyncLayout.cshtml";
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Login
|
||||||
|
{
|
||||||
|
<h2>@ViewBag.Title.</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<section id="loginForm">
|
||||||
|
@using (Html.BeginForm("FlushAsyncLogin", "Account", FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
|
||||||
|
{
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<h4>Use a local account to log in.</h4>
|
||||||
|
<hr />
|
||||||
|
@Html.ValidationSummary(true)
|
||||||
|
<div class="form-group">
|
||||||
|
@Html.LabelFor(m => m.UserName, new { @class = "col-md-2", ClAsS = "col-md-2 control-label" })
|
||||||
|
<div class="col-md-10">
|
||||||
|
@Html.TextBoxFor(m => m.UserName, new { @class = "...", cLass = "form-control" })
|
||||||
|
@Html.ValidationMessageFor(m => m.UserName)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
@Html.LabelFor(m => m.Password, new { @class = "col-md-2 control-label" })
|
||||||
|
<div class="col-md-10">
|
||||||
|
@Html.PasswordFor(m => m.Password, new { @class = "form-control" })
|
||||||
|
@Html.ValidationMessageFor(m => m.Password)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-md-offset-2 col-md-10">
|
||||||
|
<input type="submit" value="Log in" class="btn btn-default" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
@model AntiForgeryWebSite.LoginViewModel
|
||||||
|
|
||||||
|
@await FlushAsync()
|
||||||
|
|
||||||
|
<h2>@ViewBag.Title.</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<section id="loginForm">
|
||||||
|
@using (Html.BeginForm("FlushWithoutUpdatingHeader", "Account", FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
|
||||||
|
{
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<h4>Use a local account to log in.</h4>
|
||||||
|
<hr />
|
||||||
|
@Html.ValidationSummary(true)
|
||||||
|
<div class="form-group">
|
||||||
|
@Html.LabelFor(m => m.UserName, new { @class = "col-md-2", ClAsS = "col-md-2 control-label" })
|
||||||
|
<div class="col-md-10">
|
||||||
|
@Html.TextBoxFor(m => m.UserName, new { @class = "...", cLass = "form-control" })
|
||||||
|
@Html.ValidationMessageFor(m => m.UserName)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
@Html.LabelFor(m => m.Password, new { @class = "col-md-2 control-label" })
|
||||||
|
<div class="col-md-10">
|
||||||
|
@Html.PasswordFor(m => m.Password, new { @class = "form-control" })
|
||||||
|
@Html.ValidationMessageFor(m => m.Password)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-md-offset-2 col-md-10">
|
||||||
|
<input type="submit" value="Log in" class="btn btn-default" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>@ViewBag.Title – AntiForgery Functional Tests</title>
|
||||||
|
</head>
|
||||||
|
@SetAntiForgeryCookieAndHeader()
|
||||||
|
@await FlushAsync()
|
||||||
|
|
||||||
|
<body>
|
||||||
|
@Html.ActionLink("Log in", "Login", "Account", routeValues: null, htmlAttributes: new { id = "loginLink" })
|
||||||
|
<div class="container body-content">
|
||||||
|
@RenderBody()
|
||||||
|
@await RenderSectionAsync("Login", required: false)
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue